Skip to content

Latest commit

 

History

History
309 lines (214 loc) · 13.5 KB

File metadata and controls

309 lines (214 loc) · 13.5 KB

Slack Adapter

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/

Setup

1. Create Slack App

Go to api.slack.com/apps and create a new app.

2. Enable Socket Mode

  • Go to Socket Mode in the left sidebar
  • Toggle it on
  • Generate an App-Level Token with connections:write scope
  • Save the token (xapp-...) — this is your AGENT_BRIDGE_SLACK_APP_TOKEN

3. Bot Token Scopes

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

4. Event Subscriptions

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

5. Install to Workspace

Install the app and copy the Bot User OAuth Token (xoxb-...) — this is your AGENT_BRIDGE_SLACK_BOT_TOKEN.

6. Environment Variables

AGENT_BRIDGE_SLACK_BOT_TOKEN=xoxb-your-bot-token
AGENT_BRIDGE_SLACK_APP_TOKEN=xapp-your-app-level-token

Both are required. The adapter raises ValueError at startup if either is missing.

Optional: Startup Notification

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.

Optional: Channel Allow-List

Restrict which channels can reach the agent:

AGENT_BRIDGE_SLACK_ALLOW_CHANNELS=ops-alerts,team-eng,incidents
  • Comma-separated channel names (the #name, not the C0123... ID). Leading #, surrounding whitespace, and case are normalized away, so #Ops-Alerts and ops-alerts match 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_MESSAGE below) and is then dropped before any agent work.
  • AGENT_BRIDGE_SLACK_CHANNEL_NOT_ALLOWED_MESSAGE overrides 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.

Optional: Usage / Cost Report

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) and Completion.session_usage (the session's running total), which the bridge assembles from the agent's reported usage. See bridge.md for the generic Usage structure 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_usage is None and 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_

Custom template

Override the format with a {placeholder} template:

AGENT_BRIDGE_SLACK_USAGE_REPORT_TEMPLATE=💵 {cost_usd} | {total_tokens} tokens | {duration_s}s

Unknown 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

Session Semantics

One Slack thread = one agent session.

The session key format is:

slack:{channel_id}:{thread_ts}
  • channel_id — the Slack channel or DM channel ID
  • thread_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)

Per-Session State Machine

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

States

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

Pending message queue

When a session is processing and a new message arrives:

  1. The new message replaces any existing pending message (only the latest is kept)
  2. A :hourglass: Waiting for previous task to finish... message is posted
  3. The previous pending's placeholder message is deleted
  4. 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.

Event Handling

The adapter consumes BridgeEvents from the bridge and renders them as Slack messages:

Processing

Posts (or updates) an initial :hourglass_flowing_sand: Processing... message in the thread.

TextDelta

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

StatusUpdate

Appends an italic status line (e.g. _Using Read..._) below the accumulated text. Also throttled.

UserQuestion (AskUserQuestion)

When the agent needs user input:

  1. Formats questions with options for Slack display
  2. Updates (or posts) the message with the formatted question
  3. Sets session state to waiting_for_answer
  4. 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.

Completion

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

File Attachments

When users upload files in their Slack message, the adapter:

  1. Extracts file metadata: name, MIME type, and private download URL
  2. Constructs a curl command hint with the bot token for authentication
  3. 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.

Context Resolution

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.

Prompt prefix and system prompt

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 as text to bridge.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 as system_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.

Stale Session Cleanup

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.

Implementing a New Platform Adapter

Use the Slack adapter as a reference. A platform adapter must:

  1. Define session key format — how messages map to sessions
  2. Implement PlatformAdapter protocolstart() and stop()
  3. Own per-session locking — prevent concurrent processing of the same session
  4. Consume BridgeEvents — call bridge.handle_message() and render each event type
  5. Handle UserQuestion — pause and wait for user's answer
  6. Manage pending messages — decide queuing strategy (Slack keeps only the latest)

The bridge and agent require zero changes.