The Slack adapter connects Agent Bridge to Slack workspaces via Socket Mode. It defines thread = session, manages per-session concurrency, and renders agent events as real-time Slack messages.
Source: src/agent_bridge/platforms/slack/
Go to api.slack.com/apps and create a new app.
- Go to Socket Mode in the left sidebar
- Toggle it on
- Generate an App-Level Token with
connections:writescope - Save the token (
xapp-...) — this is yourAGENT_BRIDGE_SLACK_APP_TOKEN
Under OAuth & Permissions, add these Bot Token Scopes:
| Scope | Purpose |
|---|---|
app_mentions:read |
Receive @mention events in channels |
chat:write |
Send and update messages |
files:write |
Upload file snippets when response exceeds message length limit |
im:history |
Read DM message history |
im:read |
Access DM channels |
Under Event Subscriptions, subscribe to these bot events:
| Event | Purpose |
|---|---|
app_mention |
Triggers when someone @mentions the bot in a channel |
message.im |
Triggers when someone sends a DM to the bot |
Install the app and copy the Bot User OAuth Token (xoxb-...) — this is your AGENT_BRIDGE_SLACK_BOT_TOKEN.
AGENT_BRIDGE_SLACK_BOT_TOKEN=xoxb-your-bot-token
AGENT_BRIDGE_SLACK_APP_TOKEN=xapp-your-app-level-tokenBoth are required. The adapter raises ValueError at startup if either is missing.
Send a Slack message after Socket Mode connects successfully:
AGENT_BRIDGE_SLACK_STARTUP_NOTIFY_CHANNEL=C12345678
AGENT_BRIDGE_SLACK_STARTUP_NOTIFY_MESSAGE=Bot is online :white_check_mark:Both must be set for the notification to fire. Useful for deploy pipelines that need confirmation the bot is actually alive and connected.
Restrict which channels can reach the agent:
AGENT_BRIDGE_SLACK_ALLOW_CHANNELS=ops-alerts,team-eng,incidents- Comma-separated channel names (the
#name, not theC0123...ID). Leading#, surrounding whitespace, and case are normalized away, so#Ops-Alertsandops-alertsmatch the same channel. - Empty / unset = allow every channel (backward compatible).
- When non-empty, only messages from listed channels reach the agent. Anything else gets a reply in-thread (see
AGENT_BRIDGE_SLACK_CHANNEL_NOT_ALLOWED_MESSAGEbelow) and is then dropped before any agent work. AGENT_BRIDGE_SLACK_CHANNEL_NOT_ALLOWED_MESSAGEoverrides that reply text; it defaults to a fixed English notice.- DMs are also gated. A DM has no channel name (it falls back to the channel ID), so it never matches a name in the list — meaning a non-empty allow-list blocks all DMs. Leave the list empty if you want DMs to keep working.
The gate runs first in _process_message, resolving the channel name via the cached conversations:info lookup, so it costs at most one API call per distinct channel.
Append a usage/cost footer below the final agent reply:
AGENT_BRIDGE_SLACK_USAGE_REPORT_ENABLED=true- Unset / false = no footer (default).
- The data comes from
Completion.usage(this turn) andCompletion.session_usage(the session's running total), which the bridge assembles from the agent's reported usage. See bridge.md for the genericUsagestructure and how the session total is accumulated. - The session line only appears when the bridge has tracked the session from its first turn. After a process restart (the accumulator is in-memory) or for a pre-existing session,
session_usageisNoneand only the current-turn line is shown — never a misleadingly-low total.
The footer is rendered as an italic Slack footnote beneath a labelled divider (a custom template is wrapped the same way). Default layout:
─────cost─────
_💰 $0.0123 · 🔢 1234 in / 456 out · 📦 3400 cached · ⏱️ 12.3s_
_📈 session: $0.0456 · 18700 tokens · 3 turns_
Override the format with a {placeholder} template:
AGENT_BRIDGE_SLACK_USAGE_REPORT_TEMPLATE=💵 {cost_usd} | {total_tokens} tokens | {duration_s}sUnknown placeholders render blank and a malformed template degrades to its raw text (it never breaks the reply). Available placeholders — each also has a session_-prefixed variant for the running total (e.g. {session_cost_usd}); session placeholders render 0 when the session total isn't tracked:
| Placeholder | Meaning |
|---|---|
{cost_usd} |
Cost in USD, 4 decimals |
{input_tokens} / {output_tokens} |
Token counts (exclude cache) |
{cache_read_tokens} / {cache_creation_tokens} |
Cache token counts |
{total_tokens} |
Real total: input + output + cache read + cache creation |
{num_turns} |
Agent turns |
{duration_ms} / {duration_s} |
Wall-clock duration |
{duration_api_ms} |
API-only duration |
One Slack thread = one agent session.
The session key format is:
slack:{channel_id}:{thread_ts}
channel_id— the Slack channel or DM channel IDthread_ts— the thread's root message timestamp
If a message has no thread (standalone message), thread_ts falls back to the message's own ts, which starts a new thread/session.
This means:
- Every reply in the same thread continues the same agent session
- A new thread (or new standalone message) starts a fresh session
- Sessions expire after
AGENT_BRIDGE_SESSION_TTL_HOURS(default 72h)
Each session has its own state managed by _SessionState:
┌──────────────────────┐
│ IDLE │
│ processing = false │
│ waiting = false │
└──────────┬───────────┘
│ new message
▼
┌──────────────────────┐
┌────────│ PROCESSING │◀──── drain pending
│ │ processing = true │
│ └──────┬───────┬───────┘
│ │ │
│ completion │ │ AskUserQuestion
│ ▼ ▼
│ ┌─────────┐ ┌──────────────────────┐
│ │ IDLE │ │ WAITING FOR ANSWER │
│ └─────────┘ │ waiting = true │
│ └──────────┬───────────┘
│ │ user replies
│ ▼
│ ┌──────────────────────┐
└────────────────────▶│ PROCESSING │
new message while └──────────────────────┘
processing → QUEUE
| State | processing |
waiting_for_answer |
Behavior on new message |
|---|---|---|---|
| Idle | false |
false |
Start processing immediately |
| Processing | true |
false |
Queue as pending (keep only latest) |
| Waiting for answer | false |
true |
Treat as answer, resume session |
When a session is processing and a new message arrives:
- The new message replaces any existing pending message (only the latest is kept)
- A
:hourglass: Waiting for previous task to finish...message is posted - The previous pending's placeholder message is deleted
- After the current processing finishes, the pending message is drained and processed
This prevents message pileup while ensuring the latest user intent is always honored.
The adapter consumes BridgeEvents from the bridge and renders them as Slack messages:
Posts (or updates) an initial :hourglass_flowing_sand: Processing... message in the thread.
Accumulates text chunks and updates the Slack message periodically (throttled to every 1.5 seconds to respect Slack API rate limits of ~1 req/sec per method).
Appends an italic status line (e.g. _Using Read..._) below the accumulated text. Also throttled.
When the agent needs user input:
- Formats questions with options for Slack display
- Updates (or posts) the message with the formatted question
- Sets session state to
waiting_for_answer - Processing pauses until the user replies in the thread
Example Slack output:
:question: *Claude needs your input*
Should I proceed with the refactoring?
• `yes` — Apply all changes
• `no` — Abort and revert
• `partial` — Only apply safe changes
Reply in this thread to answer.
Updates the message with the final response text. Error cases:
| Scenario | Display |
|---|---|
| Normal completion | Final agent text |
| Capacity full (new request) | :no_entry: Too many requests being processed, please try again later. |
| Capacity full (pending drained) | :x: Your queued message could not be processed — please try again shortly. |
| No response | _No response from agent._ |
| Response too long (> ~3900 UTF-8 bytes) | Preview (up to 1000 bytes) + note, full content uploaded as response.md file snippet; if upload fails, user sees (response too long; upload failed — please retry) |
On a successful (non-error) completion, a usage/cost footer is appended when AGENT_BRIDGE_SLACK_USAGE_REPORT_ENABLED=true (see Usage / Cost Report).
When users upload files in their Slack message, the adapter:
- Extracts file metadata: name, MIME type, and private download URL
- Constructs a curl command hint with the bot token for authentication
- Appends the hint + file list to the user's prompt text
The agent receives something like:
user's message text
[Slack attachments — download with: curl -H "Authorization: Bearer xoxb-..." "<url>" -o /tmp/<filename>]
- report.pdf (application/pdf): https://files.slack.com/files-pri/...
- screenshot.png (image/png): https://files.slack.com/files-pri/...
The agent can then decide whether to fetch and process the files.
The adapter resolves display names for Slack entities and uses them to build the prompt prefix and the agent's system prompt:
| Field | Source | Purpose |
|---|---|---|
workspace |
team_info() API |
Workspace name |
channel_id |
Event payload | Channel identifier |
channel_name |
conversations_info() API |
Human-readable channel name |
thread_ts |
Event payload | Thread root timestamp |
user_id |
Event payload | Slack user ID |
user_name |
users_info() API |
Display name or real name |
bot_user_id |
auth.test() API at startup |
The bot's own Slack user ID — surfaced as Your Slack mention: <@U…> so the agent can detect when users @-mention it |
All resolutions are cached by SlackInfoCache to avoid repeated API calls. The bot user ID is fetched once during start() and reused for every request. The bot's display name is intentionally not surfaced — the Slack app's name and the AI agent's persona are independent concerns.
The adapter (not the agent) owns chat-platform framing. Two static helpers shape what's sent to the bridge:
_tag_prompt(text, context)— prefixes the user's message with[user_name (user_id)]:so the agent knows who is speaking. The tagged string is passed astexttobridge.handle_message._build_system_prompt(context)— produces the Slack system prompt (workspace/channel/thread metadata, the bot's mention syntax, and the convention that every message is sender-tagged) and passes it assystem_prompt.
This split keeps the agent platform-agnostic: a future platform that sends raw data (heartbeat, webhooks, queues, etc.) can produce its own prefix + system prompt without changing the agent.
A periodic cleanup task (every hour by default) removes stale session state entries — sessions that:
- Are not currently processing
- Are not waiting for an answer
- Have no pending messages
- Have expired in the
SessionManager
This prevents memory leaks from accumulated _SessionState objects.
Use the Slack adapter as a reference. A platform adapter must:
- Define session key format — how messages map to sessions
- Implement
PlatformAdapterprotocol —start()andstop() - Own per-session locking — prevent concurrent processing of the same session
- Consume
BridgeEvents — callbridge.handle_message()and render each event type - Handle
UserQuestion— pause and wait for user's answer - Manage pending messages — decide queuing strategy (Slack keeps only the latest)
The bridge and agent require zero changes.