Slack → Colibri Bridge
Status: v0 forward path live since 2026-05-31. Bot identity `@feelingofcomputing.bsky.social` (did:plc:4gcxakknd6hxtnhf33miwsob). Community owned by a separate identity (did:plc:j7nm3lrd5h7fm3sfhcv3lhfv). Auto-deployed on push.

One-way sync from the FoC Slack workspace into the Colibri atproto network. Every bridged message, reaction, and attachment is a public record on the bot's bsky.social PDS; every raw Slack event is archived losslessly under a com.feelingofcomputing.bridge.* lexicon on the same repo. The bot authors messages into channels owned by a separate community-owner DID — the inverse pattern described under Identity.

What's live

Forward path (Slack → atproto), end-to-end:

Not in scope:

Code

Architecture

sequenceDiagram
  autonumber
  actor U as User in Slack
  participant S as Slack Events API
  participant P as Producer (fetch)
  participant Q as CF Queue
  participant C as Consumer (queue)
  participant PDS as bsky.social PDS<br/>(bot's repo)
  participant App as Colibri / firehose readers

  U->>S: send / edit / delete / react
  S->>P: POST /slack/events
  P->>P: HMAC-verify X-Slack-Signature
  P->>Q: enqueue { event_callback }
  P-->>S: 200 OK (within 3s)

  C->>Q: pull batch
  C->>PDS: putRecord slackRaw (rkey = event_id)
  alt event.type = message
    C->>PDS: putRecord / deleteRecord<br/>social.colibri.message
  else event.type = reaction_added / removed
    C->>PDS: putRecord / deleteRecord<br/>social.colibri.reaction
  end
  C->>Q: ack (or retry → DLQ)

  App->>PDS: firehose / fetch
  App-->>U: public conversation visible

Notes on the live shape vs. the proposal that preceded it:

Lexicons

Reused from Colibri

Owned: com.feelingofcomputing.bridge.*

Namespace owned because FoC owns feelingofcomputing.com/.org/.net. The archive's lifetime, schema, and ownership belong to FoC, not Colibri — explicit boundary in the lexicon namespace de-risks Colibri lexicon churn (it has a substantial rework in flight on feat/rework) and de-risks Colibri disappearing entirely. If either happens, the raw archive on atproto is untouched and re-derivable.

#### Live in v0: com.feelingofcomputing.bridge.slackRaw

Lossless capture of the full event_callback envelope, written before any derivation.

{
  "lexicon": 1,
  "id": "com.feelingofcomputing.bridge.slackRaw",
  "defs": {
    "main": {
      "type": "record",
      "key": "any",
      "record": {
        "type": "object",
        "required": ["slackChannelId", "slackTs", "payload", "capturedAt"],
        "properties": {
          "slackChannelId": { "type": "string" },
          "slackTs":        { "type": "string" },
          "eventType":      { "type": "string", "description": "Slack event type: message, message_changed, message_deleted, reaction_added, reaction_removed, etc." },
          "payload":        { "type": "unknown", "description": "Raw Slack event_callback envelope as received." },
          "capturedAt":     { "type": "string", "format": "datetime" }
        }
      }
    }
  }
}

rkey = sanitised Slack event_id. Slack file attachments referenced in payload.files[] are captured by URL only; the bytes are re-uploaded as atproto blobs by the derived social.colibri.message path (see Code → Worker).

Maintenance

The bridge's per-deployment configuration lives in source, not in lexicon records. Two maps:

To add a new bridged channel: community owner creates a social.colibri.channel record under their DID inside the existing community + category, hands the rkey to the bridge maintainer, who edits both files, commits, and pushes — Cloudflare Workers Builds redeploys the worker automatically on push.

To map a new user to their DID: get their bsky handle, resolve to a DID with com.atproto.identity.resolveHandle, add the entry to both files, commit, push.

Identity

Two DIDs are involved:

Attribution for the original Slack speaker lives in the message text as @user: ... — rendered as a Colibri mention facet once the speaker has claimed a DID.

Authorship is immutable

The author of an atproto record is the DID of the repo it lives on. putRecord edits content; deleteRecord removes a record; nothing reassigns authorship. All bridged messages stay authored by the bot. Retroactive delete + republish would change the at-uri, breaking links, threads, and firehose state, so we don't.

Channels live in the bot's community

The Colibri appview hard-couples channel ownership to community ownership by author DID. From jetstream.rs:

let community_uri =
    format!("at://{}/social.colibri.community/{}", did, record.community);

The community URI for an indexed channel is constructed from the channel record's author DID plus the channel's community rkey field. A bot-authored channel record can only resolve to a community on the bot's own repo; the appview will not index a bot-authored channel into a community owned by a different DID.

v0 went with the inverse pattern: the community owner pre-creates the community + every bridged channel under their own DID; the bot only authors messages referencing the channel rkeys. The bot does not own the community, channels, or categories. Trade-off: one-time manual setup by the community owner and an ongoing convention that they add a Colibri channel whenever a new Slack channel should be bridged — but the bot's repo stays a pure archival identity, and ownership of the community is decoupled from the bridge's operational lifetime (we could replace the bot identity tomorrow without affecting the community).

Per-message avatar / displayName

Avatar and displayName come from the actor's profile record — one per DID. With one bot DID, every bridged message renders with the bot's avatar regardless of who sent it on Slack. The Colibri message lexicon has no override fields, so attribution lives in the message text body. This is the biggest visual gap and it isn't fixable without either upstream changes to the Colibri lexicon or per-user atproto identities (rejected: bsky.social account-creation rate limits and creating DIDs for users without their consent).

Operational notes

Known gaps

Things observably missing from v0. Listed as facts, not commitments.

Prior art

Related