SupaNet
Building on SupaNet

Slack

Architecture and implementation of the Slack bot and channel bindings.

The Slack integration lets the workspace bot join Slack channels and answer @mentions using collection context - the same architecture as webhooks but triggered by Slack events instead of HTTP calls. In ambient mode, the bot listens to all channel messages and a cheap model decides whether to chime in unprompted (Claude-Tag style).

Overview

Slack (app_mention or message event)
  └─► slack-events edge function (public, verify_jwt=false)
        1. Verify X-Slack-Signature HMAC (signing secret from Vault)
        2. Dedupe on event_id (unique insert into slack_events audit log)
        3. Ack 200 within 3 seconds, then in the background:

For @mentions (app_mention event):
  binding = slack_channel_bindings[channel_id]
  system = agent.instructions (or default) + loadCollectionsContext(...)
  guardrails (webhook context, fail closed)
  agent loop (collections context + tools if allowed)
  reply via chat.postMessage into the thread

For plain messages in ambient channels (message event):
  binding = slack_channel_bindings[channel_id] where mode='ambient'
  if capture_messages: save to inbox (source='slack')
  if message @mentions bot: skip (app_mention event handles it)
  if prefilter fails (trivial message): skip
  decision_model = binding.gate_model || workspace.utility_model
  verdict = decision_model(message, channel_context, participation_prompt)
  if verdict.respond: run same reply flow as @mention above

Tables

slack_integration (single row)

  • team_name - optional cosmetic label
  • bot_user_id - learned from first event, used to strip self-mentions
  • bot_token_secret_id / signing_secret_id - Vault pointers (never the actual secrets)

Credentials live only in Vault. The RPC set_slack_integration(p_bot_token, p_signing_secret) writes them (admin-gated, security-definer). The edge function reads them through read_slack_secrets() (service-role-only).

slack_channel_bindings

  • channel_id - Slack channel ID (C…/G…), unique
  • channel_name - cosmetic label (#project-acme)
  • collection_ids[] - the room's context
  • agent_id - optional agent (tools only when allow_tools=true)
  • owner_id - the user the binding runs as (their access controls apply)
  • allow_tools - deterministic gate, mirroring webhooks.allow_tools
  • is_active - pause a binding without deleting it
  • mode - 'mention' (default) or 'ambient' (listen to all messages and decide when to chime in)
  • participation_prompt - guidance text steering when to speak in ambient mode (e.g. "jump in on billing questions")
  • gate_model - optional OpenRouter slug for the ambient decision model; null falls back to workspace utility profile
  • capture_messages - when true, save channel messages into the unified inbox even if the bot stays silent

Every member can read bindings (it's a workspace surface); admins create/update/delete them. A new binding runs as its creator - so the bot inherits the creator's access to collections and tools.

slack_events (audit log)

  • event_id - Slack's event_id (unique, for deduplication)
  • channel_id / slack_user_id - who asked in which channel
  • kind - event type (always app_mention for now)
  • status - receivedok / error / blocked / skipped
  • text - the (mention-stripped) request as the model saw it
  • result - the bot's reply
  • error - if something went wrong

Admins read the log; only the edge function (service role) writes.

Edge function: slack-events

Location: supabase/functions/slack-events/index.ts

Access: Public (verify_jwt: false), gated by HMAC signature only.

Request handling

  1. Signature verification - computes HMAC-SHA256 of the request with the signing secret from Vault, constant-time compares, checks replay window (±5 min).
  2. Dedupe - inserts an slack_events row with the unique event_id. Slack retries on slow acks; the unique constraint gates the second run.
  3. 3-second ack - responds 200 immediately so Slack knows the webhook is alive.
  4. Background processing - via EdgeRuntime.waitUntil, the function does the model work and Slack reply.

Processing loop

binding = slack_channel_bindings[channel_id]
text = slackTextToPlain(stripBotMention(event.text))

// Guardrails (fail closed, like webhooks)
guard = runGuardrails(db, 'webhook', text, binding.owner_id)
if guard.blocked: respond "⛔ That was blocked", log it, return

// Agent or plain assistant
systemPrompt = agent?.instructions || default
collectionIds = binding.collection_ids ∪ agent?.collection_ids

// Tools only if allowed
tools = binding.allow_tools ? agent.tool_ids : []

collCtx = loadCollectionsContext(db, collectionIds, binding.owner_id, MODEL)
systemPrompt += collCtx

// Conversational context
transcript = fetchSlackTranscript(channel_id, thread_ts?)
userContent = formatTranscript(transcript) + user.asks + text

// Agent loop
messages = [system, user_content]
for turn in 0..MAX_TOOL_TURNS:
  out = orComplete(messages, tools, ...)
  recordUsage(context='slack', ...)
  if out.toolCalls:
    dispatch tools (http / builtin / mcp) 
    append results, continue
  else: break

reply = toMrkdwn(result)
postSlackMessage(channel, reply, threadTs)

Calling Slack Web API

Pure helper functions in _shared/slack.ts:

  • postSlackMessage(botToken, channel, text, threadTs?) - posts the reply (threaded if needed)
  • fetchSlackTranscript(botToken, channel, threadTs?, limit?) - gets recent context
  • fetchSlackUserNames(botToken, userIds[]) - resolves display names

All three use the bot token from Vault. They are throw-free, returning empty results on error so a bad API call does not crash the flow.

Shared utilities: _shared/slack.ts

Pure logic for testing:

  • computeSlackSignature(secret, timestamp, body) - HMAC-SHA256
  • verifySlackSignature({signingSecret, body, timestamp, signature}) - verify + replay window
  • stripBotMention(text, botUserId?) - remove <@bot_id> from the message
  • slackTextToPlain(text) - decode Slack markup (<@U1|name>@name, <url|label>label (url), entities like &amp;&)
  • toMrkdwn(markdown) - convert model markdown to Slack mrkdwn (**bold***bold*, [link](url)<url|link>, headings → bold)
  • shouldSkipEvent(event) - returns a skip reason or null: filters bot messages, edits, empty text, missing user/channel
  • formatTranscript(messages, nameMap, excludeTs?) - render recent messages as "name: text" lines, skip the triggering message

Ambient mode helpers:

  • mentionsBot(text, botUserId?) - returns true if the text @mentions the bot; those arrive as their own app_mention event, so the ambient path skips them (no double reply)
  • passesAmbientPrefilter(text) - heuristic gate run BEFORE the model, filters out trivial messages (short, emoji-only, links, etc.) so busy channels don't cost a model call per message
  • buildParticipationSystem(guidance) - system prompt for the ambient decision model, with "default to silent" posture and channel-specific guidance
  • parseParticipationVerdict(text) - parses the decision model's JSON verdict {respond: boolean, reason: string}, fails silent on errors (false positive spam is worse than a miss)

All unit-tested in supabase/functions/tests/slack_test.ts.

RPC functions

set_slack_integration(p_bot_token, p_signing_secret, p_team_name?) (admin-gated, security-definer)

  • Creates or updates the single slack_integration row
  • Both secrets are write-only; empty on update means keep existing
  • Stores them in Vault via vault.create_secret() / vault.update_secret()

delete_slack_integration() (admin-gated)

  • Removes credentials; bindings stay (they're inert without them)

read_slack_secrets() (service-role-only)

  • Called by the edge function to get the decrypted bot token, signing secret, and learned bot_user_id
  • Forbidden from API roles (anon/authenticated)

slack_is_configured() (authenticated)

  • Simple boolean check for the UI

Security notes

  • No URL token - the HMAC signature is the only gate. Signing secret must be kept safe (Vault-backed, write-only in the RPC).
  • Deduplication - event_id is unique; Slack retries are idempotent.
  • Fail closed - guardrails run in webhook context with block: true - any error or violation blocks the message.
  • No loops - bot messages (bot_id field) and message edits (subtype field) are skipped, so the bot cannot answer itself.
  • Read-only by default - allow_tools=false means the bot runs with no tools, only the model.
  • Per-user access - the binding runs as its creator, so collections context and builtin tools re-enforce that user's access in code.

Binding a room

  1. Invite the bot to a Slack channel: /invite @assistant
  2. Get the channel ID from channel details → About
  3. Admin goes to Settings → Slack → Bind a channel
    • Paste the channel ID
    • Pick collections (at least one for context, or none for plain assistant)
    • Optionally pick an agent
    • Check Allow tools if you want the agent to act
  4. Save the binding

The binding's creator (owner_id) is the identity the bot runs as. Bind workspace-visibility collections for shared rooms so the answer respects what everyone already has access to.

Editing a binding

Click Edit on an existing binding's card to modify its settings. The form preserves the current values and allows you to update:

  • Collections the room can access
  • The agent (including ambient-mode-specific settings)
  • Allow/disallow tools
  • Participation prompt and gate model (for ambient bindings)
  • Message capture setting

The channel ID and owner are immutable. To move a binding to a different channel, delete it and create a new one.

Usage tracking and auditing

  • slack_events table - admin-readable audit log of every mention (who, channel, status, transcript/reply)
  • activity_log - slack.reply entries for successful replies, guardrail.blocked / .flagged for blocked mentions
  • usage_events - every model call records context='slack' for the Usage page

Future directions

  • DMs - handle message.im (direct messages to the bot) and answer with the DM-er's own context (private collections, their files)
  • send_slack_message builtin - agents can proactively post into bound rooms
  • In-channel commands - /slash commands to manage bindings without leaving Slack
  • Per-thread cooldown - rate limit ambient replies in a single thread to avoid spam
  • Display name resolution - captured inbox messages currently store the raw Slack user id; resolve to display names for better readability

On this page