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). The send_slack_message builtin also lets agents, scheduled runs, and webhooks proactively post into Slack channels.

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

const MAX_TOOL_TURNS = 16  // Slack @mentions get chat-sized runway for complex multi-step tasks
const MAX_TOKENS = 8000    // Per-completion output budget
const WALL_CLOCK_MS = 130_000  // Stop 20s before platform kills the worker (130s default, configurable via SLACK_MAX_WALL_MS)

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 with wall-clock guard
messages = [system, user_content]
result = ''
const startedAt = Date.now()
for turn in 0..MAX_TOOL_TURNS:
  // Finalize before platform timeout: post what we have rather than dropping the reply
  if (Date.now() - startedAt > WALL_CLOCK_MS):
    if (!result) result = 'This took longer than I could spend on it — try narrowing the request.'
    break
  
  out = orComplete(messages, tools, maxTokens: MAX_TOKENS, ...)
  recordUsage(context='slack', ...)
  result = out.content || result
  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.

Proactive posting: send_slack_message builtin

The send_slack_message builtin (handler in _shared/builtins.ts) is the inverse of the reactive @mention path: agents, scheduled runs, chat, and webhooks can now initiate posts to Slack instead of only replying to mentions.

Implementation:

  • Validates channel (required, either ID or name) and message text (required)
  • Rate-limits: 30 posts per rolling hour workspace-wide (activity_log type slack.sent)
  • Reads the bot token from Vault via read_slack_secrets() (service-role-only)
  • Calls postSlackMessage(botToken, channel, text, threadTs?) from _shared/slack.ts
  • Logs slack.sent on success, slack.error on Slack API failure
  • Returns the result as a human-readable string

Access control (like send_email):

  • Exfiltration-capable tool — gated by admin activation (default inactive in migration 0107)
  • Agent tool_ids scoping applies
  • Webhook allow_tools deterministic gate applies
  • Every post logs to activity feed for auditing

Input schema:

{
  "channel": "C0123456 or #general",
  "text": "The message to post",
  "thread_ts": "ts of parent message (optional, for replies)"
}

Seeding (migration 0107): The builtin is created with is_active = false so an admin must turn it on once Slack is connected.

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.
  • Wall-clock timeout - The bot stops the tool loop and posts a reply 20 seconds before the platform kills the worker (130 seconds by default, overridable with SLACK_MAX_WALL_MS). This ensures a response is posted rather than timing out with no output.

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)
  • 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