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 aboveTables
slack_integration (single row)
team_name- optional cosmetic labelbot_user_id- learned from first event, used to strip self-mentionsbot_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…), uniquechannel_name- cosmetic label (#project-acme)collection_ids[]- the room's contextagent_id- optional agent (tools only whenallow_tools=true)owner_id- the user the binding runs as (their access controls apply)allow_tools- deterministic gate, mirroringwebhooks.allow_toolsis_active- pause a binding without deleting itmode-'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 workspaceutilityprofilecapture_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'sevent_id(unique, for deduplication)channel_id/slack_user_id- who asked in which channelkind- event type (alwaysapp_mentionfor now)status-received→ok/error/blocked/skippedtext- the (mention-stripped) request as the model saw itresult- the bot's replyerror- 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
- Signature verification - computes HMAC-SHA256 of the request with the signing secret from Vault, constant-time compares, checks replay window (±5 min).
- Dedupe - inserts an
slack_eventsrow with the uniqueevent_id. Slack retries on slow acks; the unique constraint gates the second run. - 3-second ack - responds 200 immediately so Slack knows the webhook is alive.
- 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 contextfetchSlackUserNames(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-SHA256verifySlackSignature({signingSecret, body, timestamp, signature})- verify + replay windowstripBotMention(text, botUserId?)- remove<@bot_id>from the messageslackTextToPlain(text)- decode Slack markup (<@U1|name>→@name,<url|label>→label (url), entities like&→&)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/channelformatTranscript(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 ownapp_mentionevent, 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 messagebuildParticipationSystem(guidance)- system prompt for the ambient decision model, with "default to silent" posture and channel-specific guidanceparseParticipationVerdict(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_integrationrow - 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_idis unique; Slack retries are idempotent. - Fail closed - guardrails run in
webhookcontext withblock: true- any error or violation blocks the message. - No loops - bot messages (
bot_idfield) and message edits (subtypefield) are skipped, so the bot cannot answer itself. - Read-only by default -
allow_tools=falsemeans 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
- Invite the bot to a Slack channel:
/invite @assistant - Get the channel ID from channel details → About
- 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
- 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_eventstable - admin-readable audit log of every mention (who, channel, status, transcript/reply)activity_log-slack.replyentries for successful replies,guardrail.blocked/.flaggedfor blocked mentionsusage_events- every model call recordscontext='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_messagebuiltin - agents can proactively post into bound rooms- In-channel commands -
/slashcommands 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