Let your team talk to your data, tools, and apps β directly from Slack.
User: "What's new this week?"
Bot: "3 items this week β Q1 roadmap update, Acme's Series B,
and the sales pipeline review..."
Connect it to a database, an API, an internal tool β whatever your team needs to query. They just ask the bot in plain English.
Clone. Set 3 keys. Run.
- Word-overlap search β natural queries like "series b funding" find the right items, not just exact substrings. Title matches rank higher.
- π Processing indicator + streaming replies β the bot reacts with π the instant your message arrives, posts a placeholder reply, then streams Claude's text into it as it's generated. Two signals: "I see you" and "answer forming now."
- Thread context β follow-up questions work naturally. The bot pages
conversations.repliesto the end of the thread (it returns oldest-first, so the recent messages are on the last page), and correctly tells its own past replies apart from other bots' messages. - Tool loop β Claude picks the right tool, reads the results, and replies. Up to 10 model turns per message, with oversized tool payloads truncated so one big result can't crowd out the conversation.
- Slack-safe formatting β markdown is translated to Slack's dialect; code spans and fenced blocks pass through untouched so
**pointers**andx ** 2survive intact; and control characters are escaped, so a tool row containing<!channel>renders as text instead of paging everyone. - Long-response chunking β answers longer than 3,500 characters auto-split on paragraph boundaries and post as a chain of replies in the same thread. (That's our margin, not Slack's hard cap β it keeps replies clear of truncation.)
- Duplicate-event protection β Slack redelivers events when a socket reconnects. Repeats are dropped instead of producing a second reply.
- Clean shutdown β SIGTERM/SIGINT close the Socket Mode connection and release tool resources (DB pools) before exiting, instead of dropping the process mid-request.
- Optional channel allowlist β set
SLACK_ALLOWED_CHANNELSto restrict @-mention responses to specific channels (DMs always allowed). Defense against accidental exposure if the bot is invited somewhere unexpected. - Single config source β model, timeout, and retry defaults live in one place. No drift between files.
graph LR
A["π¬ Slack"] -->|"your team asks a question"| B["π§ Agent"]
B -->|"picks the right tool"| C["π§ Tools"]
C -->|"queries"| D["π¦ Your Data"]
D -->|"results"| C
C -->|"answers"| B
B -->|"replies in thread"| A
style A fill:#4A154B,color:#fff,stroke:#4A154B
style B fill:#D97706,color:#fff,stroke:#D97706
style C fill:#2563EB,color:#fff,stroke:#2563EB
style D fill:#059669,color:#fff,stroke:#059669
Someone messages your bot. Claude figures out what they're asking, calls the right tool, gets data back, and replies in the thread. You decide what tools exist and what data they can access.
Six files. Two are yours:
| File | What it does |
|---|---|
src/tools.ts |
Yours β your data and what the team can ask for. Start here. |
src/index.ts |
Yours β wiring and policy: persona, shutdown, extra Slack handlers. |
src/slack.ts |
The machine: Socket Mode, threads, streaming, chunking |
src/agent.ts |
The machine: Claude loop, contracts, model invariants |
src/format.ts |
The machine: markdown β Slack mrkdwn |
src/config.ts |
The machine: env parsing + exported defaults |
Everything the bot can do is defined in the two files marked yours. That's also the review rule: a diff touching the other four deserves a second look.
graph LR
subgraph "Your Bot (stateless)"
A["Message in"] --> B["Claude processes"] --> C["Reply out"]
end
subgraph "Where data lives"
D["Slack<br/>Messages stay in Slack"]
E["Anthropic API<br/>Subject to their<br/>retention policy"]
end
B -.->|"API call"| E
A -.->|"stored by"| D
C -.->|"stored by"| D
style A fill:#059669,color:#fff,stroke:none
style B fill:#D97706,color:#fff,stroke:none
style C fill:#059669,color:#fff,stroke:none
style D fill:#4A154B,color:#fff,stroke:none
style E fill:#2563EB,color:#fff,stroke:none
Out of the box, this bot stores nothing. No database, no logs, no conversation history. Messages live in Slack. API calls go to Anthropic (see their data retention policy).
If you add things β a database, an MCP server, a third-party API β those things can store data. That's where you need to be careful. Each integration you add is a new place where conversations or query results might be logged, cached, or persisted. See Security for what to watch for.
You need 3 things: a Slack app, an Anthropic key, and this repo.
graph LR
A["1οΈβ£ Create Slack App<br/>Get 2 tokens"] --> B["2οΈβ£ Get Anthropic Key<br/>~$5 credits"]
B --> C["3οΈβ£ Clone & Run<br/>Paste tokens in .env"]
style A fill:#4A154B,color:#fff,stroke:none
style B fill:#D97706,color:#fff,stroke:none
style C fill:#059669,color:#fff,stroke:none
- Go to api.slack.com/apps β Create New App β From a manifest
- Pick your workspace, then paste the contents of
manifest.json - Basic Information β App-Level Tokens β generate one with
connections:write(starts withxapp-) - Install to Workspace β copy the bot token (starts with
xoxb-)
That's it β the manifest sets Socket Mode, all five scopes, and both events in one shot. manifest.json is the source of truth for what the bot can read; see Security for what each scope allows.
Changed scopes or events? Reinstall the app to the workspace. Channel-only (no DMs)? Remove
im:historyandmessage.imfrom the manifest before pasting. Want private channels? Addgroups:history, reinstall, and invite the bot.
- Go to console.anthropic.com
- Create an API key
- Add credits (~$5 is plenty to start)
- Set a monthly spend cap β there's no built-in rate limiting in the bot
git clone https://github.com/Mikeishiring/slackbot.git && cd slackbot
npm install
cp .env.example .env # then paste your 3 tokens
npm startWindows PowerShell
Copy-Item .env.example .env
npm install
npm startNot technical? You can skip the terminal entirely. Install the Claude Code CLI with the Chrome extension, open this repo, and ask Claude to set everything up for you β Slack app, Anthropic key, Railway deployment, all of it. It can use your browser to click through the setup pages autonomously.
- Invite the bot to a channel:
/invite @YourBotName - Send:
@YourBotName what's new this week? - Try a DM too β just message the bot directly
Expected: the bot replies in a thread using the sample dataset.
npm run check runs the typechecker and the test suite locally. Requires Node 22 or newer β earlier versions' test runners don't discover TypeScript test files.
π€ Agent / automated setup (Claude Code, Cursor, Codex)
If you're using an AI coding agent to set this up:
- Slack App β use the App Manifest JSON editor (
Settings β App Manifests), not individual pages. Setsocket_mode_enabled: true, scopes + events in one shot. - Tokens β app-level token with
connections:write, bot token from OAuth. Both in.env. - Scopes β
reactions:writeis included by default for the π processing indicator. Skipim:historyfor channel-only mode. - Railway β set vars via Raw Editor or GraphQL (
variableCollectionUpsert), not one-by-one. - Verify β
npm run checklocally, then push. Railway auto-deploys.
π src/
βββ tools.ts β β YOURS β your data + your tools. Start here.
βββ index.ts β β YOURS β wiring, persona, extra Slack handlers
βββ slack.ts β Socket Mode, thread history, streaming, chunking
βββ agent.ts β Claude loop + the contracts the app speaks
βββ format.ts β markdown β Slack mrkdwn
βββ config.ts β Env vars, defaults, validation
π data/
βββ sample-data.json β Starter dataset (swap this out)
π test/ β Contract tests (agent, slack, tools, format, config)
βββ tools.example.test.ts β π copy this when you swap the data source
π manifest.json β Slack app manifest β paste to create the app
π .env.example β Template β copy to .env and fill in
Here's what happens every time someone messages your bot:
sequenceDiagram
participant S as Slack
participant A as Agent (Claude)
participant T as tools.ts
S->>A: "What happened with Acme?"
A->>T: search_items({query: "Acme"})
T-->>A: [{title: "Acme Series B", id: "item-002"...}]
A->>T: get_item({id: "item-002"})
T-->>A: {content: "Acme raised $45M..."}
A-->>S: "Acme announced a $45M Series B led by Sequoia..."
Claude decides which tools to call, how many times (up to 10), and how to phrase the answer. You define what tools exist and what data they return.
The starter ships with 3 read-only tools against a sample JSON file:
| Tool | What it does |
|---|---|
search_items |
Keyword search with optional tag filter |
get_item |
Full details for one item by ID |
list_recent |
Most recent items (default: last 7 days) |
This is where you make it yours. The bot can talk to anything β a database, a REST API, an internal tool, a spreadsheet, a CRM. You're really just answering three questions:
graph TD
A["1οΈβ£ What can your team ask?"] -->|"tool definitions"| B["Search, lookup, report, summarize..."]
C["2οΈβ£ Where does the answer live?"] -->|"data source"| D["Database, API, file, MCP server..."]
E["3οΈβ£ How do you get it?"] -->|"tool implementation"| F["SQL query, fetch call, SDK method..."]
style A fill:#2563EB,color:#fff,stroke:none
style C fill:#D97706,color:#fff,stroke:none
style E fill:#059669,color:#fff,stroke:none
style B fill:#1e40af,color:#fff,stroke:none
style D fill:#92400e,color:#fff,stroke:none
style F fill:#065f46,color:#fff,stroke:none
Open src/tools.ts and swap the sample data for your real source. Every recipe below is a change in one of your two files β nothing else moves.
| I want to⦠| Where | How |
|---|---|---|
| Point at my own data | tools.ts β loadSampleFile |
Replace the body. It's async, so a query or fetch drops in. Then see the note below β your tool tests will go red until you repoint them. |
| Add a capability | tools.ts β LOCAL_TOOLS |
Add one LocalTool object β schema and run together. |
| Change the persona | .env β ANTHROPIC_SYSTEM_PROMPT_APPEND |
One line. Longer personas go in index.ts as systemPromptAppend. |
| Restrict a tool to certain people | tools.ts β that tool's run |
if (!ALLOWED.has(context.userId ?? "")) return { error: "Not authorized" }; |
| Log which user asked what | tools.ts β that tool's run |
context carries userId, channelId, threadTs. |
| See token cost per turn | already on | agent.ts logs one JSON line per model turn with tokens, channel, and user. A tool can't do this β it only sees its own call, not the model turns that dominate the bill. |
| Add a slash command | index.ts |
bot.app.command(...) β a commented example is in the file. |
| Let it search the web | tools.ts β SERVER_TOOLS |
Uncomment the web_search line. No implementation needed. |
| Close a DB pool on exit | tools.ts β closeTools |
Called automatically on SIGTERM/SIGINT. |
Swapping the data source turns the shipped tool tests red β that's expected. test/tools.test.ts asserts against sample-data IDs like item-002, so once loadSampleFile points elsewhere those assertions no longer describe your data.
Copy test/tools.example.test.ts and delete test/tools.test.ts. The template uses setItemSource() to swap in a fixture, so your tests keep passing with no live database, no network, and no mocking library β which matters most at exactly the moment you're changing the thing they cover.
Connect a database:
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL);
function searchItems(query: string) {
return sql`SELECT * FROM items WHERE title ILIKE ${'%' + query + '%'} LIMIT 10`;
}Call a REST API:
async function searchItems(query: string) {
const res = await fetch(`https://api.example.com/search?q=${query}`);
return res.json();
}Some ideas: connect it to your CRM so the team can ask "what deals closed this week?", hook it up to your analytics API for "how's traffic looking?", or point it at an internal wiki so people can ask "what's our refund policy?" β anything your team currently has to go dig for manually.
You start with one file and three tools. As you add more, the structure grows with you:
graph LR
subgraph "Day 1"
A["tools.ts<br/>3 tools, 1 file"]
end
subgraph "Growing"
B["tools/<br/>index.ts"]
C["search.ts"]
D["reports.ts"]
E["actions.ts"]
B --> C
B --> D
B --> E
end
subgraph "Multi-source"
F["tools/<br/>index.ts"]
G["Local tools"]
H["MCP servers"]
F --> G
F --> H
end
A -.->|"split into folder"| B
B -.->|"add external sources"| F
style A fill:#059669,color:#fff,stroke:none
style B fill:#2563EB,color:#fff,stroke:none
style C fill:#1e40af,color:#fff,stroke:none
style D fill:#1e40af,color:#fff,stroke:none
style E fill:#1e40af,color:#fff,stroke:none
style F fill:#D97706,color:#fff,stroke:none
style G fill:#92400e,color:#fff,stroke:none
style H fill:#92400e,color:#fff,stroke:none
The key thing: slack.ts, agent.ts, format.ts, and config.ts never change. agent.ts imports tools and runTool from whatever you give it β one file, a folder of files, or a mix of local tools and external MCP servers. Persona and policy go in index.ts; data and capabilities go in tools.ts.
When you outgrow a single file, split tools.ts into a tools/ folder. When you want to connect external services, add MCP servers alongside your local tools. The bot doesn't care where the tools come from.
Model Context Protocol lets you plug in external tool servers instead of coding everything in tools.ts. Think of it like adding plugins.
graph LR
subgraph "Your Bot"
A["Agent"] --> B["Local Tools<br/>(tools.ts)"]
A --> C["MCP Client"]
end
C --> D["π Analytics<br/>MCP Server"]
C --> E["ποΈ Database<br/>MCP Server"]
C --> F["π Files<br/>MCP Server"]
style A fill:#D97706,color:#fff,stroke:none
style B fill:#2563EB,color:#fff,stroke:none
style C fill:#7C3AED,color:#fff,stroke:none
style D fill:#059669,color:#fff,stroke:none
style E fill:#059669,color:#fff,stroke:none
style F fill:#059669,color:#fff,stroke:none
Local (tools.ts) |
MCP Server | |
|---|---|---|
| Best for | Simple queries, single data source | Shared services, pre-built integrations |
| Setup | Edit one file | Run a server + connect |
| Trust | You wrote it | Audit what it exposes |
Start local. Move to MCP when you need multiple bots sharing the same data, or when a pre-built MCP server already does what you need.
Read this before deploying. This bot runs code that has the Slack permissions you granted it.
| Scope | What it allows |
|---|---|
app_mentions:read |
Read any message that @mentions the bot |
chat:write |
Post messages to any channel the bot is in |
channels:history |
Read message history in public channels the bot is in |
reactions:write |
Add/remove emoji reactions (used for π processing indicator) |
im:history |
Read direct messages sent to the bot |
These five are what manifest.json requests. im:history is what makes DMs work β remove it and the message.im event for channel-only mode.
When you deploy this bot, you're trusting three things:
The code in this repo. tools.ts and index.ts define what the bot actually does. Anyone with write access to the repo or the deployment can change what happens when the bot is mentioned. A malicious change to either could make the bot read channel history and exfiltrate it, post misleading messages, or misuse the Slack API. Audit both before deploying β they're the two files that should change.
The Anthropic API. Claude processes your Slack messages. Anything said to the bot goes through Anthropic's API. Review their data usage policy.
Your deployment platform. Whoever has access to your Railway/hosting environment can see your tokens and modify the running code.
It reads direct messages sent to it. With the default manifest, anything DM'd to the bot is sent to the Anthropic API β and SLACK_ALLOWED_CHANNELS does not apply to DMs. If that isn't what you want, drop im:history and the message.im event.
It reads every message in a thread it replies in β not just the one that mentioned it. All of them go to the model as conversation input, whatever the author: a teammate, another bot, a webhook integration, or an outside org in a Slack Connect channel. The model can't tell a bystander's message from the request. That makes thread history an input an attacker can write to, and since the bot's reply lands in the same thread, the reply is the way data gets back out.
In channels, it reads history only where it's been invited.
- It does not read history in private channels (no
groups:historyscope). Note it will still answer a direct @mention in a private channel it's been invited to β it just answers without thread context. Don't invite it where that isn't wanted. - It does not manage channels, users, or workspace settings
- It does not store messages β thread history is fetched on demand and discarded after the response
- It does not have a database β it's completely stateless
graph TB
subgraph "π’ Safe by Default"
A["Stateless β no data stored"]
B["Read-only tools only"]
C["Socket Mode β no public URL"]
end
subgraph "π‘ Watch When Extending"
D["Adding Slack scopes"]
E["Connecting a database"]
F["No rate limiting on API spend"]
end
subgraph "π΄ High Risk"
G["Write tools without confirmation"]
H["Untrusted MCP servers"]
I["Secrets in system prompt"]
end
style A fill:#059669,color:#fff,stroke:none
style B fill:#059669,color:#fff,stroke:none
style C fill:#059669,color:#fff,stroke:none
style D fill:#D97706,color:#fff,stroke:none
style E fill:#D97706,color:#fff,stroke:none
style F fill:#D97706,color:#fff,stroke:none
style G fill:#DC2626,color:#fff,stroke:none
style H fill:#DC2626,color:#fff,stroke:none
style I fill:#DC2626,color:#fff,stroke:none
1. Audit tools.ts and index.ts before deploying. Both are about one screen. Everything the bot can do is defined there. A diff to slack.ts, agent.ts, format.ts, or config.ts is a red flag β understand why before merging.
2. Limit channel access. Only invite the bot to channels where you want it. It can only read history in channels it's been invited to.
A channel ID doesn't change when that channel is later shared externally through Slack Connect, so SLACK_ALLOWED_CHANNELS keeps matching while the audience quietly gains an outside organization. Re-check the allowlist whenever a channel is shared.
3. Use minimal scopes. This bot does not request groups:history (private channels). If you only need channel mentions, drop im:history and message.im from manifest.json too. Security here is about omission β you secure it by not granting access, not by configuring something extra.
4. Rotate tokens if you suspect compromise. Revoke and regenerate both the bot token and app token from api.slack.com/apps.
5. Pin your dependencies. Run npm audit before deploying. Supply chain attacks through npm packages are a real vector.
6. Keep the Anthropic API key scoped. Use a dedicated key for this bot, not your org-wide key. Set a monthly spend cap β there's no built-in rate limiting.
An LLM is not a security boundary. If you give the bot a database connection, assume a skilled user can get Claude to query anything that connection can reach. System prompt instructions like "never return PII" are suggestions, not walls β they can be bypassed through prompt injection.
This isn't a flaw β it's how LLMs work. Plan for it:
- Keep tools read-only β this bounds the damage to disclosure, not to nothing. A read-only tool still posts whatever it can read back into the channel the injected text came from. Assume anything the connection can reach is publishable to that thread's audience
- Scope your credentials β read-only replica, only the tables the bot needs, row-level security
- Don't put secrets in the system prompt β assume it can be extracted
- Validate tool inputs in
runTool()β don't blindly trust what Claude passes in - Enforce access at the data layer (row-level security, view permissions), never at the prompt layer
MCP servers are powerful β and that's the risk. When you connect one, you're giving Claude access to whatever that server exposes.
- Only connect servers you control or trust β a malicious server can inject prompts through tool results
- Audit tool lists before connecting (
client.listTools()) - Run MCP servers in the same private network as the bot β not on the public internet
- If you can do it in
tools.ts, do it there β don't add an external dependency you don't need
Ship read-only, scope tight, don't store what you don't need. Audit tools.ts before every deploy. Treat every MCP server and database connection like a dependency β vet it before you trust it.
Locally:
npm startRailway (recommended): Push to GitHub β New Project β Deploy from GitHub β add env vars β done. Logs should show Bot is running (Socket Mode).
Other hosts: Fly.io, Render, DigitalOcean, Docker β anything that runs npm start and stays alive. No public URL needed β Socket Mode connects outbound.
Not technical? Use Claude Code with the Chrome extension to deploy for you. Ask it to create a Railway project, set your environment variables, and push β it can handle the entire deployment through your browser.
| Variable | Required | Default | Notes |
|---|---|---|---|
SLACK_BOT_TOKEN |
Yes | β | Starts with xoxb- |
SLACK_APP_TOKEN |
Yes | β | Starts with xapp- |
ANTHROPIC_API_KEY |
Yes | β | |
ANTHROPIC_MODEL |
No | claude-opus-5 |
|
ANTHROPIC_SYSTEM_PROMPT_APPEND |
No | β | Adds your context to the prompt. One-liners only β see note below. |
ANTHROPIC_MAX_TOKENS |
No | 16000 |
Caps thinking + reply together |
ANTHROPIC_EFFORT |
No | medium |
low | medium | high | xhigh | max |
ANTHROPIC_REQUEST_TIMEOUT_MS |
No | 120000 |
Per attempt, not per message |
ANTHROPIC_MAX_RETRIES |
No | 2 |
|
SLACK_ALLOWED_CHANNELS |
No | β (any channel) | Comma-separated channel IDs |
ANTHROPIC_EFFORT is the main dial. The model thinks before it answers, and effort controls how much:
lowβ fastest and cheapest. Handles most "look this up and tell me" questions well.medium(default) β a good balance for a bot that has to pick tools and read results.high/xhigh/maxβ for genuinely hard questions. Slower and more expensive;maxcan overthink simple lookups.
Start at the default, drop to low if replies feel slow, and raise it only if answers come back shallow.
Don't disable thinking to save money β lower the effort instead. With thinking off, this model will occasionally write a tool call as plain text instead of actually calling the tool, and the bot will answer confidently without ever having looked anything up.
ANTHROPIC_SYSTEM_PROMPT_APPEND adds your context after the shipped prompt:
ANTHROPIC_SYSTEM_PROMPT_APPEND=You support the Acme billing team. Prices are in USD.
Keep it to a sentence or two.
dotenvdrops unquoted newlines, so a multi-paragraph persona in.envloads silently truncated. For anything longer, passsystemPromptAppendinindex.tsinstead β orsystemPromptto replace the prompt entirely, after reading the note onDEFAULT_SYSTEM_PROMPTinagent.tsabout the one paragraph worth keeping.
Deliberate omissions, so you don't go looking:
- HTTP mode β Socket Mode only. No
SLACK_SIGNING_SECRET, no public URL. - Serverless / Lambda β the WebSocket, signal handlers, and streaming edits all assume a long-running process. It's a rewrite, not a config flag.
- Block Kit output β the reply path is a string end to end (streamed, then chunked on characters). Rich layouts would be a second write path.
- Images and files β DMs with attachments get an honest "I can't read files yet" reply rather than silence.
- Native MCP β
mcp_serverslives onclient.beta.messages, so the cheap path would mean editingagent.ts. Wrap an MCP client as a tool intools.tsinstead.
| Component | Monthly cost |
|---|---|
| Slack | Free |
| Anthropic API | ~$5β50 depending on usage |
| Railway | ~$5β20 |
What drives cost: Every message is one or more API calls. Longer tool responses and deeper threads use more tokens. A team of 10 with moderate usage runs about $10β20/month.
| Problem | Fix |
|---|---|
| Bot doesn't respond | Check scopes + event subscriptions. Reinstall app after changes. |
Bot is running but no replies |
Invite the bot: /invite @YourBotName |
not_found_error on model |
ANTHROPIC_MODEL isn't a valid ID, or the model was retired. Clear it to use the default. |
| Socket keeps disconnecting | Check SLACK_APP_TOKEN starts with xapp- |
| Replies cut off mid-sentence | Raise ANTHROPIC_MAX_TOKENS β it covers thinking and the reply. |
| Replies feel slow | Lower ANTHROPIC_EFFORT to low. Don't disable thinking. |
| Answers ignore your data | Confirm the tool actually returns rows β at low effort with thinking off, tool calls can be skipped. |
Failed to resolve bot user ID in logs |
Non-fatal. History attribution falls back to a heuristic; check the bot token is valid. |
| High API costs | Set spend cap in Anthropic Console. Lower ANTHROPIC_EFFORT. Reduce tool response sizes. |
Missing required environment variable |
Check .env has all 3 required vars filled in |
This repo is intentionally small. Two files are yours: swap the sample JSON in src/tools.ts for your database, API, or MCP server, set the persona in .env or src/index.ts, and ship it.