Slack Bot
Build a Slack bot that lets your team submit AI prompts and receive results directly in Slack channels or threads.
Testing tip — Build and verify this integration end-to-end with convoy-mock first. It’s a free, synthetic model that returns a callback in ~60 seconds and is never billed. Set DEFAULT_MODEL=convoy-mock while wiring the bot up, then switch to claude-3-haiku once the Slack flow works.
Use Case: Team AI Assistant
Send a message in Slack, get an AI-generated response back in the same thread. Perfect for:
- Content teams — “Draft a blog intro about our new feature” → AI response in-thread
- Support teams — “Summarize this customer issue and suggest a response” → AI drafts a reply
- Engineering teams — “Write a PR description for these changes” → AI generates the description
- Anyone — Quick AI-powered writing, summarization, or analysis without leaving Slack
┌──────────────┐ ┌──────────────┐ ┌─────────┐
│ Slack User │──msg──▶ │ Your Bot │──POST──▶│ Convoy │
│ @convoy │ │ (Server) │ │ API │
└──────────────┘ └──────┬───────┘ └────┬────┘
│ │
│ (minutes–hours later)│
│ │
┌──────▼───────┐ ┌────▼────┐
│ Webhook │◀──POST──│ Convoy │
│ Endpoint │ │ Worker │
└──────┬───────┘ └─────────┘
│
┌──────▼───────┐
│ Slack API │
│ (Reply) │
└──────────────┘Since Convoy processes in batches (minutes to hours, up to 24-hour SLA), this bot is best for background tasks like content generation, not real-time chat. The bot acknowledges immediately and replies when the result is ready.
Prerequisites
- A Convoy account with a project API key (
convoy_sk_...) - A Slack workspace where you can install apps
- Node.js 18+ or Python 3.10+
- A publicly accessible server (or ngrok for development)
Step 1: Create a Slack App
- Go to api.slack.com/apps and click Create New App
- Choose From scratch
- Name it “Convoy AI” and select your workspace
- Click Create App
Configure Bot Permissions
-
Go to OAuth & Permissions in the sidebar
-
Under Bot Token Scopes, add:
app_mentions:read— Detect when users mention the botchat:write— Send messageschannels:history— Read channel messages (for thread context)groups:history— Read private channel messages
-
Click Install to Workspace and authorize
-
Copy the Bot User OAuth Token (starts with
xoxb-)
Enable Event Subscriptions
- Go to Event Subscriptions in the sidebar
- Toggle Enable Events to ON
- Set the Request URL to your server’s endpoint:
https://your-server.com/slack/events - Under Subscribe to bot events, add:
app_mention— Triggers when someone @mentions your bot
- Click Save Changes
Step 2: Build the Bot Server
Node.js Implementation
mkdir convoy-slack-bot && cd convoy-slack-bot
npm init -y
npm install @slack/bolt dotenv// app.js
require('dotenv').config();
const { App } = require('@slack/bolt');
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
port: process.env.PORT || 3000,
});
// Store pending requests to match callbacks to Slack threads
const pendingRequests = new Map();
// Handle @convoy mentions
app.event('app_mention', async ({ event, say }) => {
// Extract the prompt (remove the @mention)
const prompt = event.text.replace(/<@[A-Z0-9]+>/g, '').trim();
if (!prompt) {
await say({
text: '👋 Hi! Mention me with a prompt and I\'ll generate a response. Example:\n`@Convoy AI Write a blog intro about serverless computing`',
thread_ts: event.ts,
});
return;
}
// Acknowledge immediately
await say({
text: `⏳ Working on it! I'll reply here when the result is ready (usually minutes to hours).`,
thread_ts: event.ts,
});
// Submit to Convoy
try {
const response = await fetch(`${process.env.CONVOY_BASE_URL}/cargo/load`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.CONVOY_API_KEY,
},
body: JSON.stringify({
params: {
model: process.env.DEFAULT_MODEL || 'claude-3-haiku',
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }],
},
callback_url: `${process.env.PUBLIC_URL}/convoy/callback`,
}),
});
const data = await response.json();
if (!response.ok) {
await say({
text: `❌ Failed to submit: ${data.detail || 'Unknown error'}`,
thread_ts: event.ts,
});
return;
}
// Store the mapping: cargo_id → Slack thread info
pendingRequests.set(data.cargo_id, {
channel: event.channel,
thread_ts: event.ts,
user: event.user,
prompt: prompt.slice(0, 100),
});
console.log(`Submitted ${data.cargo_id} for user ${event.user}`);
} catch (error) {
console.error('Error submitting to Convoy:', error);
await say({
text: `❌ Error connecting to Convoy: ${error.message}`,
thread_ts: event.ts,
});
}
});
// Handle Convoy callbacks
app.receiver.app.post('/convoy/callback', async (req, res) => {
// Collect the body
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true }));
try {
const payload = JSON.parse(body);
const { cargo_id, success, response, error } = payload;
const request = pendingRequests.get(cargo_id);
if (!request) {
console.warn(`No pending request found for ${cargo_id}`);
return;
}
pendingRequests.delete(cargo_id);
if (success) {
const text = response.content[0].text;
const model = response.model;
const tokens = response.usage.output_tokens;
await app.client.chat.postMessage({
channel: request.channel,
thread_ts: request.thread_ts,
text: text,
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: text },
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `_${model} · ${tokens} tokens · <@${request.user}>_`,
},
],
},
],
});
} else {
await app.client.chat.postMessage({
channel: request.channel,
thread_ts: request.thread_ts,
text: `❌ Processing failed: ${error}`,
});
}
} catch (err) {
console.error('Error processing callback:', err);
}
});
});
(async () => {
await app.start();
console.log('⚡️ Convoy Slack bot is running!');
})();.env file:
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_SIGNING_SECRET=your-signing-secret
CONVOY_API_KEY=convoy_sk_your_key_here
CONVOY_BASE_URL=https://api.cnvy.ai
PUBLIC_URL=https://your-server.com
DEFAULT_MODEL=claude-3-haiku
PORT=3000Python Implementation
pip install slack-bolt flask httpx python-dotenv# app.py
import os
import json
from dotenv import load_dotenv
from slack_bolt import App
from slack_bolt.adapter.flask import SlackRequestHandler
from flask import Flask, request, jsonify
import httpx
load_dotenv()
app = App(
token=os.environ["SLACK_BOT_TOKEN"],
signing_secret=os.environ["SLACK_SIGNING_SECRET"],
)
flask_app = Flask(__name__)
handler = SlackRequestHandler(app)
# Store pending requests
pending_requests: dict[str, dict] = {}
CONVOY_API_KEY = os.environ["CONVOY_API_KEY"]
CONVOY_BASE_URL = os.environ.get("CONVOY_BASE_URL", "https://api.cnvy.ai")
PUBLIC_URL = os.environ["PUBLIC_URL"]
DEFAULT_MODEL = os.environ.get("DEFAULT_MODEL", "claude-3-haiku")
@app.event("app_mention")
def handle_mention(event, say):
"""Handle @convoy mentions."""
# Extract prompt (remove @mention)
import re
prompt = re.sub(r"<@[A-Z0-9]+>", "", event["text"]).strip()
if not prompt:
say(
text="👋 Mention me with a prompt! Example:\n`@Convoy AI Write a blog intro about AI`",
thread_ts=event["ts"],
)
return
# Acknowledge immediately
say(
text="⏳ Working on it! I'll reply here when ready (minutes to hours).",
thread_ts=event["ts"],
)
# Submit to Convoy
try:
response = httpx.post(
f"{CONVOY_BASE_URL}/cargo/load",
headers={
"Content-Type": "application/json",
"X-API-Key": CONVOY_API_KEY,
},
json={
"params": {
"model": DEFAULT_MODEL,
"max_tokens": 2048,
"messages": [{"role": "user", "content": prompt}],
},
"callback_url": f"{PUBLIC_URL}/convoy/callback",
},
)
response.raise_for_status()
data = response.json()
pending_requests[data["cargo_id"]] = {
"channel": event["channel"],
"thread_ts": event["ts"],
"user": event["user"],
}
print(f"Submitted {data['cargo_id']} for user {event['user']}")
except Exception as e:
say(text=f"❌ Error: {e}", thread_ts=event["ts"])
@flask_app.route("/convoy/callback", methods=["POST"])
def convoy_callback():
"""Receive results from Convoy."""
payload = request.get_json()
cargo_id = payload["cargo_id"]
req_info = pending_requests.pop(cargo_id, None)
if not req_info:
return jsonify({"received": True, "warning": "unknown cargo_id"})
if payload["success"]:
text = payload["response"]["content"][0]["text"]
model = payload["response"]["model"]
tokens = payload["response"]["usage"]["output_tokens"]
app.client.chat_postMessage(
channel=req_info["channel"],
thread_ts=req_info["thread_ts"],
text=text,
blocks=[
{"type": "section", "text": {"type": "mrkdwn", "text": text}},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": f"_{model} · {tokens} tokens · <@{req_info['user']}>_",
}
],
},
],
)
else:
app.client.chat_postMessage(
channel=req_info["channel"],
thread_ts=req_info["thread_ts"],
text=f"❌ Processing failed: {payload['error']}",
)
return jsonify({"received": True})
@flask_app.route("/slack/events", methods=["POST"])
def slack_events():
return handler.handle(request)
if __name__ == "__main__":
flask_app.run(port=int(os.environ.get("PORT", 3000)))Step 3: Deploy and Configure
Local Development (ngrok)
# Start your bot
node app.js # or: python app.py
# In another terminal, expose it
ngrok http 3000Copy the ngrok URL (e.g., https://abc123.ngrok.io) and:
- Set
PUBLIC_URL=https://abc123.ngrok.ioin your.env - Update the Slack Event Subscriptions Request URL to
https://abc123.ngrok.io/slack/events
Production Deployment
Deploy to any platform that supports long-running Node.js/Python processes:
- Railway / Render — Simple PaaS deployment
- AWS ECS — If you’re already running Convoy on AWS
- Fly.io — Global edge deployment
For production, use a database (Redis, PostgreSQL) instead of an in-memory Map to store pending requests. This ensures callbacks are handled correctly even if the bot restarts.
Step 4: Using the Bot
Once deployed, invite the bot to a channel:
/invite @Convoy AIThen mention it with any prompt:
@Convoy AI Write a professional email declining a meeting invitation politelyThe bot will:
- Acknowledge immediately with ⏳
- Submit the prompt to Convoy
- Reply in the same thread when the result arrives (minutes to hours)
Advanced Features
Model Selection with Slash Commands
Add a slash command for model control:
- In your Slack app settings, go to Slash Commands
- Create
/convoywith the request URLhttps://your-server.com/slack/commands
app.command('/convoy', async ({ command, ack, say }) => {
await ack();
// Parse: /convoy [model] prompt
const parts = command.text.split(' ');
let model = process.env.DEFAULT_MODEL;
let prompt = command.text;
const validModels = ['claude-3-haiku', 'claude-3-sonnet', 'claude-3-opus'];
if (validModels.includes(parts[0])) {
model = parts[0];
prompt = parts.slice(1).join(' ');
}
// Submit to Convoy with the selected model
// ... (same as app_mention handler but with model parameter)
});Usage:
/convoy claude-3-sonnet Write a detailed product roadmap for Q3
/convoy Write a quick tweet about our launchSystem Prompts per Channel
Configure different system prompts for different channels:
const channelPrompts = {
'C01MARKETING': 'You are a marketing copywriter. Be engaging and concise.',
'C02SUPPORT': 'You are a customer support specialist. Be empathetic and helpful.',
'C03ENGINEERING': 'You are a senior software engineer. Be technical and precise.',
};
app.event('app_mention', async ({ event, say }) => {
const systemPrompt = channelPrompts[event.channel] || 'You are a helpful assistant.';
// Include system prompt in the Convoy request...
});Thread Context
Include previous messages in the thread for context-aware responses:
app.event('app_mention', async ({ event, say, client }) => {
// Fetch thread history for context
let messages = [];
if (event.thread_ts) {
const result = await client.conversations.replies({
channel: event.channel,
ts: event.thread_ts,
limit: 10,
});
messages = result.messages
.filter(m => !m.bot_id) // Exclude bot messages
.map(m => ({
role: 'user',
content: m.text.replace(/<@[A-Z0-9]+>/g, '').trim(),
}));
} else {
messages = [{ role: 'user', content: prompt }];
}
// Submit with full conversation context
// ...
});Usage Tracking
Track usage per user or channel:
// After receiving a callback
const usage = response.usage;
console.log(JSON.stringify({
event: 'convoy_result',
user: request.user,
channel: request.channel,
model: response.model,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cargo_id: cargo_id,
}));Troubleshooting
Bot doesn’t respond to mentions
- Verify the bot is invited to the channel (
/invite @Convoy AI) - Check Event Subscriptions are enabled and the URL is verified
- Ensure
app_mentions:readscope is granted - Check your server logs for incoming events
”not_authed” or “invalid_auth” errors
- Verify
SLACK_BOT_TOKENstarts withxoxb- - Re-install the app to your workspace if the token was regenerated
Callback never arrives in Slack
- Check that
PUBLIC_URLis correct and publicly accessible - Verify the Convoy request was accepted (check for
cargo_idin logs) - Use the tracking endpoint to check if the cargo is still processing
- Ensure your callback endpoint returns 200 within 30 seconds
Messages are too long for Slack
- Slack has a 4,000 character limit per message block
- Split long responses into multiple messages:
function splitMessage(text, maxLength = 3900) {
const chunks = [];
while (text.length > 0) {
chunks.push(text.slice(0, maxLength));
text = text.slice(maxLength);
}
return chunks;
}Rate limiting
- Slack API: 1 message per second per channel
- Convoy API: 60 requests per minute per project
- Add queuing if your team generates many simultaneous requests