Most Slack bots are glorified keyword matchers. You send a command, it spits back a canned response. We can do better. By wiring up Slack Bolt with OpenAI’s tool calling, you get a bot that actually reasons about what you’re asking, picks the right action, and executes it. Thread summaries, channel searches, reminders – all driven by an LLM that understands context.
Here’s the minimal setup to get a working agent bot:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| import os
import json
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from openai import OpenAI
app = App(token=os.environ["SLACK_BOT_TOKEN"])
openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
SYSTEM_PROMPT = """You are a helpful Slack assistant. You can:
- Summarize conversation threads
- Search channels for information
- Create reminders for users
Use the provided tools when a user's request matches one of these capabilities.
Respond concisely. You're in Slack, not writing an essay."""
@app.event("app_mention")
def handle_mention(event, say, client):
thread_ts = event.get("thread_ts", event["ts"])
user_message = event["text"].split(">", 1)[-1].strip()
channel_id = event["channel"]
response = run_agent(user_message, channel_id, event, client)
say(text=response, thread_ts=thread_ts)
if __name__ == "__main__":
handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
handler.start()
|
That’s the skeleton. The bot listens for mentions, sends the message to our agent function, and replies in the thread. Now let’s build the interesting parts.
Setting Up the Slack App and Permissions#
Before writing more code, you need a Slack app configured correctly. Go to api.slack.com/apps, create a new app from scratch, and configure these:
Bot Token Scopes (under OAuth & Permissions):
app_mentions:read – triggers when someone @mentions your botchannels:history – read messages in public channels for thread summarieschannels:read – list and search channelschat:write – send messages backreminders:write – create reminders on behalf of users
Socket Mode: Enable it under Settings. This avoids needing a public URL during development. You get a SLACK_APP_TOKEN (starts with xapp-).
Event Subscriptions: Subscribe to app_mention under bot events.
Install the app to your workspace to get a SLACK_BOT_TOKEN (starts with xoxb-).
Install the Python dependencies:
1
| pip install slack-bolt openai
|
Set your environment variables:
1
2
3
| export SLACK_BOT_TOKEN="xoxb-your-bot-token"
export SLACK_APP_TOKEN="xapp-your-app-token"
export OPENAI_API_KEY="sk-your-openai-key"
|
OpenAI’s tool calling lets the model decide when to invoke a function based on the user’s message. Define your tools as JSON schemas that describe what each function does and what parameters it accepts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
| TOOLS = [
{
"type": "function",
"function": {
"name": "summarize_thread",
"description": "Summarize a Slack thread conversation. Use when a user asks for a summary of a thread or conversation.",
"parameters": {
"type": "object",
"properties": {
"channel_id": {
"type": "string",
"description": "The Slack channel ID where the thread lives"
},
"thread_ts": {
"type": "string",
"description": "The timestamp of the parent message in the thread"
}
},
"required": ["channel_id", "thread_ts"]
}
}
},
{
"type": "function",
"function": {
"name": "search_channels",
"description": "Search across Slack channels for messages matching a query.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search term to look for in channel messages"
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "create_reminder",
"description": "Create a reminder for a user at a specified time.",
"parameters": {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "The Slack user ID to set the reminder for"
},
"text": {
"type": "string",
"description": "The reminder message"
},
"time": {
"type": "string",
"description": "When to trigger the reminder, e.g. 'in 30 minutes', 'tomorrow at 9am'"
}
},
"required": ["user_id", "text", "time"]
}
}
}
]
|
Each tool needs a real implementation that hits the Slack API. This is where the bot actually does things:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
| def execute_summarize_thread(channel_id, thread_ts, slack_client):
result = slack_client.conversations_replies(
channel=channel_id,
ts=thread_ts,
limit=50
)
messages = result["messages"]
if len(messages) <= 1:
return "This thread only has the original message. Nothing to summarize."
conversation_text = ""
for msg in messages:
user = msg.get("user", "unknown")
text = msg.get("text", "")
conversation_text += f"<@{user}>: {text}\n"
summary_response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Summarize this Slack thread concisely. Highlight key decisions, action items, and open questions. Keep it under 200 words."},
{"role": "user", "content": conversation_text}
],
temperature=0.3
)
return summary_response.choices[0].message.content
def execute_search_channels(query, slack_client):
result = slack_client.conversations_list(types="public_channel", limit=100)
channels = result["channels"]
matches = []
for channel in channels:
channel_id = channel["id"]
try:
history = slack_client.conversations_history(
channel=channel_id,
limit=20
)
for msg in history["messages"]:
if query.lower() in msg.get("text", "").lower():
matches.append({
"channel": channel["name"],
"text": msg["text"][:200],
"ts": msg["ts"]
})
except Exception:
continue # bot might not be in every channel
if not matches:
return f"No messages found matching '{query}'."
results_text = f"Found {len(matches)} match(es) for '{query}':\n"
for m in matches[:5]:
results_text += f"- #{m['channel']}: {m['text']}\n"
return results_text
def execute_create_reminder(user_id, text, time_str, slack_client):
try:
slack_client.reminders_add(
user=user_id,
text=text,
time=time_str
)
return f"Reminder set for <@{user_id}>: '{text}' at {time_str}"
except Exception as e:
return f"Failed to create reminder: {str(e)}"
|
Notice that execute_summarize_thread calls the LLM internally – an agent calling a tool that calls another LLM. The outer model decides when to summarize, the inner model does the actual summarization. Using gpt-4o-mini for the summary keeps costs down since the main agent handles the reasoning.
This is the core of the bot. Send the user’s message to OpenAI with tool definitions. If the model wants to call a tool, execute it and feed the result back. Loop until the model produces a final text response:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
| def run_agent(user_message, channel_id, event, slack_client, max_iterations=5):
thread_ts = event.get("thread_ts", event["ts"])
user_id = event["user"]
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": (
f"User <@{user_id}> in channel {channel_id} "
f"(thread {thread_ts}) says: {user_message}"
)
}
]
tool_dispatch = {
"summarize_thread": lambda args: execute_summarize_thread(
args.get("channel_id", channel_id),
args.get("thread_ts", thread_ts),
slack_client
),
"search_channels": lambda args: execute_search_channels(
args["query"],
slack_client
),
"create_reminder": lambda args: execute_create_reminder(
args.get("user_id", user_id),
args["text"],
args["time"],
slack_client
),
}
for _ in range(max_iterations):
response = openai_client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=TOOLS,
tool_choice="auto",
temperature=0.2
)
assistant_message = response.choices[0].message
messages.append(assistant_message)
if not assistant_message.tool_calls:
return assistant_message.content or "I couldn't generate a response."
for tool_call in assistant_message.tool_calls:
fn_name = tool_call.function.name
try:
fn_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": "Error: Invalid JSON in tool arguments. Please try again."
})
continue
if fn_name in tool_dispatch:
result = tool_dispatch[fn_name](fn_args)
else:
result = f"Unknown tool: {fn_name}"
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
})
return "I hit my reasoning limit. Try a simpler question."
|
The max_iterations cap prevents runaway loops. Five iterations handles most tasks – search, summarize, maybe create a reminder. The tool_choice="auto" setting lets the model decide whether to use a tool or respond directly.
Handling Conversation Context in Threads#
Slack threads are natural conversation containers. To give the bot memory within a thread, fetch previous messages and include them in the prompt:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| def get_thread_context(channel_id, thread_ts, slack_client, max_messages=10):
result = slack_client.conversations_replies(
channel=channel_id,
ts=thread_ts,
limit=max_messages
)
context_messages = []
for msg in result.get("messages", []):
if msg.get("bot_id"):
context_messages.append({"role": "assistant", "content": msg["text"]})
else:
context_messages.append({"role": "user", "content": msg["text"]})
return context_messages
|
Use this in your event handler to inject thread history between the system prompt and the current message. The model then knows what was discussed earlier in the thread and can reference previous answers.
Trimming Context to Stay Within Token Limits#
Thread history can get long. Truncate old messages and keep a rolling window:
1
2
3
4
5
6
7
8
9
| def trim_context(messages, max_chars=12000):
"""Keep recent messages, drop oldest if context is too large."""
total_chars = sum(len(m.get("content", "")) for m in messages)
while total_chars > max_chars and len(messages) > 1:
removed = messages.pop(0)
total_chars -= len(removed.get("content", ""))
return messages
|
This keeps the most recent conversation turns and drops older ones when you approach the limit. Not sophisticated, but good enough for Slack conversations where the last few messages carry the most context.
Preventing Duplicate Responses#
Slack retries events if your bot takes more than 3 seconds to respond. Without deduplication, you’ll see double replies. Track processed events with a simple set:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| import threading
seen_events = set()
seen_lock = threading.Lock()
@app.event("app_mention")
def handle_mention(event, say, client):
event_ts = event["ts"]
with seen_lock:
if event_ts in seen_events:
return
seen_events.add(event_ts)
if event.get("bot_id"):
return
user_message = event["text"].split(">", 1)[-1].strip()
channel_id = event["channel"]
thread_ts = event.get("thread_ts", event["ts"])
thread_context = get_thread_context(channel_id, thread_ts, client)
response = run_agent(user_message, channel_id, event, client)
say(text=response, thread_ts=thread_ts)
|
The bot_id check prevents the bot from responding to its own messages or other bots – this avoids infinite reply loops that burn through your OpenAI credits.
Common Errors and Fixes#
slack_bolt.error.BoltError: 'token' is required
You forgot to set SLACK_BOT_TOKEN. Socket Mode also requires SLACK_APP_TOKEN – these are two different tokens. Double-check your exports:
1
2
3
4
| export SLACK_BOT_TOKEN="xoxb-your-bot-token"
export SLACK_APP_TOKEN="xapp-your-app-level-token"
export OPENAI_API_KEY="sk-your-key"
python bot.py
|
slack_sdk.errors.SlackApiError: not_in_channel
The bot needs to be invited to a channel before it can read messages. Either /invite @YourBot in the channel or add the channels:join scope and call client.conversations_join(channel=channel_id) before fetching history.
Bot doesn’t respond to mentions
Three things to check: (1) the bot is actually invited to the channel, (2) app_mention event subscription is enabled in your Slack app config, (3) Socket Mode is connected – you should see “Bolt app is running” in your terminal output.
openai.BadRequestError: 'tool_calls' must be followed by tool results
This happens when you append the assistant message containing tool_calls but don’t follow it with matching tool role messages for every tool call. The model sometimes requests multiple tools in parallel. Make sure you loop through all tool_calls, not just the first one.
json.decoder.JSONDecodeError when parsing tool arguments
The model occasionally returns malformed JSON in tool_call.function.arguments. The agent loop code above already handles this with a try/except that sends an error back to the model so it can retry.
Rate limits from Slack API
The search_channels function iterates over channels and their history, which can hit Slack’s rate limits (roughly 1 request per second for most endpoints). Add time.sleep(0.5) between API calls or use Slack’s search.messages endpoint if your app has the search:read scope.
Bot responds twice to the same message
Slack retries events when acknowledgment is slow. Use the event deduplication pattern shown in the previous section. For production, consider a proper queue (Redis + Celery) instead of an in-memory set.