# How to Build a Slack AI Agent

- Tool: Slack Bolt
- Difficulty: Intermediate
- Time required: 40-55 min
- Compatibility: Python 3.10+, slack-bolt, Anthropic API key, a Slack workspace where you can install an app
- Last updated: November 2026

## TL;DR

A Slack AI agent connects over Socket Mode with Slack Bolt, reads @mentions, and runs Claude's tool-use loop to answer with real data instead of a canned script. This guide builds an on-call bot that searches your runbook for anyone and pages the on-call engineer only for allowlisted requesters. Stack: Python, slack-bolt, the Anthropic SDK. Budget 40-55 minutes and a free Slack workspace.

## What you'll build

You'll build a Slack bot for an engineering team's #incidents channel. Anyone can @mention it to search the runbook; a smaller group of on-call admins can ask it to page the on-call engineer, and it refuses that second request from anyone else before the model ever gets a say.

Slack Bolt's Socket Mode gives you a live event stream with no public URL to expose. Each app_mention event becomes one call into a tool-use loop you write yourself: send the message and your tool schemas to Claude, execute whichever tools it asks for, send the results back, and repeat until it has a final answer. Writing the loop by hand instead of reaching for the Claude Agent SDK keeps this page focused on the Slack wiring; swap in the SDK later if you want permission hooks and subagents.

Choose this pattern when you want a Slack-native bot with full control over what each tool is allowed to do and no extra framework between Slack's events and Claude's API. If you'd rather not manage the event loop yourself, the Claude Agent SDK guide builds a similar approval flow with less boilerplate.

## Before you start

- A Slack workspace where you can create and install an app
- An Anthropic API key (ANTHROPIC_API_KEY) with available credits
- pip install slack-bolt anthropic
- Comfort reading Python; no prior Slack API experience needed

## Step-by-step guide

### 1. Create the Slack app and turn on Socket Mode

At api.slack.com/apps, create a new app from scratch. Under OAuth & Permissions, add the bot scopes app_mentions:read, chat:write, and channels:history, then install the app to your workspace and copy the Bot User OAuth Token (starts xoxb-). Under Socket Mode, turn it on and generate an app-level token with the connections:write scope (starts xapp-). Under Event Subscriptions, subscribe to the app_mention bot event.

```
export SLACK_BOT_TOKEN="xoxb-..."
export SLACK_APP_TOKEN="xapp-..."
export ANTHROPIC_API_KEY="sk-ant-..."
```

**Expected result:** Three environment variables set, and the app shows as installed under 'Your Apps' in the Slack workspace.

### 2. Define the two tools and their schemas

search_runbook is read-only: it takes a query string and returns matching runbook sections (stub it with a dict lookup here; swap for a real search index later). page_oncall is a write tool: it takes a service name and an incident summary and, when it actually runs, posts to your paging system. Define a plain Python function for each plus a Claude tool schema dict describing its name, description, and JSON input_schema.

```
RUNBOOK = {"checkout": "Checkout 500s usually mean the payments provider is down. Check status.stripe.com first."}

def search_runbook(query: str) -> str:
    for key, text in RUNBOOK.items():
        if key in query.lower():
            return text
    return "No runbook entry found for that query."

def page_oncall(service: str, summary: str) -> str:
    # In production this calls PagerDuty/Opsgenie's API
    return f"Paged on-call for {service}: {summary}"

TOOLS = [
    {"name": "search_runbook", "description": "Search the incident runbook by keyword.",
     "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}},
    {"name": "page_oncall", "description": "Page the on-call engineer for a service. Restricted to on-call admins.",
     "input_schema": {"type": "object", "properties": {"service": {"type": "string"}, "summary": {"type": "string"}}, "required": ["service", "summary"]}},
]
```

**Expected result:** Two callable functions and a TOOLS list ready to pass into Claude's messages.create call.

### 3. Write the tool-use loop

Call client.messages.create with the TOOLS list and the running message history. If the response's stop_reason is tool_use, loop through its content blocks, run the matching Python function for each tool_use block (checking the allowlist for page_oncall first), append a tool_result message for each, and call messages.create again. When stop_reason is end_turn, collect the text blocks as the final answer.

```
import anthropic
client = anthropic.Anthropic()

def run_agent(messages, requester_id):
    while True:
        resp = client.messages.create(
            model="claude-sonnet-5-5", max_tokens=1024,
            tools=TOOLS, messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")
        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            output = call_tool(block.name, block.input, requester_id)
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
        messages.append({"role": "user", "content": results})
```

**Expected result:** A run_agent function that keeps calling Claude until it has a final text answer, running tools in between.

### 4. Gate page_oncall behind an allowlist before it ever runs

call_tool is where the allowlist check lives, not inside page_oncall itself, so the rule is enforced the same way no matter how many write tools you add later. If the requester's Slack user ID isn't in ON_CALL_ADMINS, return a refusal string as the tool result instead of calling the real function; Claude sees that refusal and explains it to the user.

```
ON_CALL_ADMINS = {"U01ABCDEF", "U02GHIJKL"}

def call_tool(name, input_data, requester_id):
    if name == "search_runbook":
        return search_runbook(input_data["query"])
    if name == "page_oncall":
        if requester_id not in ON_CALL_ADMINS:
            return "Denied: only on-call admins can trigger a page. Ask one of them to run this."
        return page_oncall(input_data["service"], input_data["summary"])
    return f"Unknown tool: {name}"
```

**Expected result:** A request to page on-call from a non-admin user gets a polite refusal instead of an actual page.

### 5. Wire it into Slack Bolt with per-thread history

Listen for app_mention events, strip the bot's own mention text out of the message, and look up or start a message history for that thread so follow-up questions keep context. Pass event['user'] through as requester_id so the allowlist check in step 4 has something to check against. Reply with say(), threaded to the original message.

```
import os, re
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

app = App(token=os.environ["SLACK_BOT_TOKEN"])
THREADS: dict[str, list] = {}

@app.event("app_mention")
def handle_mention(event, say):
    thread_key = event.get("thread_ts") or event["ts"]
    text = re.sub(r"<@\w+>", "", event["text"]).strip()
    history = THREADS.setdefault(thread_key, [])
    history.append({"role": "user", "content": text})
    reply = run_agent(history, event["user"])
    say(text=reply, thread_ts=thread_key)

if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()
```

**Expected result:** Mentioning the bot in #incidents gets a threaded reply, and asking again in the same thread keeps context.

### 6. Test both paths and harden before go-live

Ask a runbook question as any user, confirm it answers without hesitation. Then ask to page a service as a non-admin user and confirm the refusal, then as an admin and confirm it runs. Before shipping to a real #incidents channel: cap THREADS size or move it to Redis so it survives a restart, wrap call_tool in try/except so a broken runbook lookup returns an error string instead of crashing the process, and log every tool call with the requester ID for audit.

```
def call_tool(name, input_data, requester_id):
    try:
        if name == "search_runbook":
            result = search_runbook(input_data["query"])
        elif name == "page_oncall":
            if requester_id not in ON_CALL_ADMINS:
                result = "Denied: only on-call admins can trigger a page."
            else:
                result = page_oncall(input_data["service"], input_data["summary"])
        else:
            result = f"Unknown tool: {name}"
    except Exception as e:
        result = f"Tool error: {e}"
    print(f"[tool] {name} by {requester_id} -> {result}")
    return result
```

**Expected result:** A bot that never crashes on a bad tool call and leaves an audit line for every tool it ran.

## Complete code example

File: `slack_oncall_agent.py`

```python
"""Slack on-call bot: answers runbook questions, pages on-call only for allowlisted admins.
Run: python slack_oncall_agent.py
(pip install slack-bolt anthropic; set SLACK_BOT_TOKEN, SLACK_APP_TOKEN, ANTHROPIC_API_KEY)
"""
import os
import re

import anthropic
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

client = anthropic.Anthropic()

RUNBOOK = {
    "checkout": "Checkout 500s usually mean the payments provider is down. Check status.stripe.com first.",
    "login": "Login failures spiking? Check the auth service's connection pool to the user DB first.",
}

ON_CALL_ADMINS = {"U01ABCDEF", "U02GHIJKL"}
THREADS: dict[str, list] = {}

TOOLS = [
    {
        "name": "search_runbook",
        "description": "Search the incident runbook by keyword.",
        "input_schema": {
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        },
    },
    {
        "name": "page_oncall",
        "description": "Page the on-call engineer for a service. Restricted to on-call admins.",
        "input_schema": {
            "type": "object",
            "properties": {
                "service": {"type": "string"},
                "summary": {"type": "string"},
            },
            "required": ["service", "summary"],
        },
    },
]


def search_runbook(query: str) -> str:
    for key, text in RUNBOOK.items():
        if key in query.lower():
            return text
    return "No runbook entry found for that query."


def page_oncall(service: str, summary: str) -> str:
    # In production this calls PagerDuty/Opsgenie's API
    return f"Paged on-call for {service}: {summary}"


def call_tool(name: str, input_data: dict, requester_id: str) -> str:
    try:
        if name == "search_runbook":
            result = search_runbook(input_data["query"])
        elif name == "page_oncall":
            if requester_id not in ON_CALL_ADMINS:
                result = "Denied: only on-call admins can trigger a page. Ask one of them to run this."
            else:
                result = page_oncall(input_data["service"], input_data["summary"])
        else:
            result = f"Unknown tool: {name}"
    except Exception as e:  # noqa: BLE001
        result = f"Tool error: {e}"
    print(f"[tool] {name} by {requester_id} -> {result}")
    return result


def run_agent(messages: list, requester_id: str) -> str:
    while True:
        resp = client.messages.create(
            model="claude-sonnet-5-5",
            max_tokens=1024,
            system="You are an on-call assistant. Always search the runbook before answering an incident question.",
            tools=TOOLS,
            messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            return "".join(b.text for b in resp.content if b.type == "text")
        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            output = call_tool(block.name, block.input, requester_id)
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
        messages.append({"role": "user", "content": results})


app = App(token=os.environ["SLACK_BOT_TOKEN"])


@app.event("app_mention")
def handle_mention(event, say):
    thread_key = event.get("thread_ts") or event["ts"]
    text = re.sub(r"<@\w+>", "", event["text"]).strip()
    history = THREADS.setdefault(thread_key, [])
    history.append({"role": "user", "content": text})
    reply = run_agent(history, event["user"])
    say(text=reply, thread_ts=thread_key)


if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()
```

## Common mistakes

- **Checking the requester's identity inside page_oncall instead of in call_tool** — undefined Fix: Put every permission check in one place (call_tool) so adding a third write tool later doesn't mean re-implementing the allowlist.
- **Subscribing to the message event instead of app_mention** — undefined Fix: message fires for every message in every channel the bot can see, including its own replies, which can loop. app_mention only fires when someone explicitly @mentions the bot.
- **Keeping THREADS as an unbounded in-memory dict in production** — undefined Fix: It grows forever and resets on every deploy. Move it to Redis or a database once you're past local testing.
- **Forgetting to add the app-level token's connections:write scope** — undefined Fix: Socket Mode silently fails to connect without it. Regenerate the app token with that scope checked.
- **Letting a tool's exception crash the whole Bolt event handler** — undefined Fix: Wrap call_tool in try/except and return an error string Claude can react to, not a stack trace that kills the process.

## Best practices

- Keep allowlists and other permission checks in the tool dispatcher, not inside individual tool functions.
- Log every tool call with who asked for it, independent of whether it was allowed.
- Thread replies with say(thread_ts=...) so a busy #incidents channel stays readable.
- Scope Slack bot tokens to only the scopes you actually use; app_mentions:read, chat:write, and channels:history cover this guide.
- Cap tool-use loop iterations (e.g. break after 6 rounds) so a confused model can't loop forever on one Slack message.
- State the tool-use rule explicitly in the system prompt: search the runbook before answering, never claim a page went out without calling page_oncall.

## Use cases

### undefined



### undefined



### undefined



## Frequently asked questions

### Why write the tool-use loop by hand instead of using the Claude Agent SDK?

A hand-written loop makes every line of the request/response cycle visible, which is useful for learning the pattern or for a bot this small. Once you need permission hooks, subagents, or built-in tools, the Claude Agent SDK guide gets you there with far less code.

### Does Socket Mode require a public server or webhook URL?

No. Socket Mode opens an outbound WebSocket connection from wherever you run the script, so it works behind a firewall or on your laptop with no ngrok tunnel or public endpoint needed.

### What happens if two people @mention the bot in the same thread at once?

Each app_mention event runs handle_mention independently and both append to the same THREADS[thread_key] list, so in this simple version the second call can see the first user's message as context. Add a lock per thread_key if that overlap matters for your use case.

### Can I use a different model to cut cost on simple runbook lookups?

Yes. Route search_runbook-only requests to Claude Haiku 5.5 and only use Sonnet 5.5 when page_oncall might be involved. A cheap keyword check on the incoming text before calling run_agent can decide which model to use.

### How do I stop the bot from responding to its own messages?

Listening for app_mention instead of message already avoids this, since the bot doesn't mention itself. If you add a message listener later, check event.get('bot_id') and skip it.

### Can RapidDev build this kind of Slack agent for our team?

Yes. RapidDev builds Slack and Teams agents wired into real incident, support, and ops tooling, including the allowlist and audit-trail pieces most tutorials skip. A free 30-minute consultation is the fastest way to scope yours.

---

Source: https://www.rapidevelopers.com/build-ai-agent/how-to-build-a-slack-ai-agent
© RapidDev — https://www.rapidevelopers.com/build-ai-agent/how-to-build-a-slack-ai-agent
