clawskills-mcp 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -41,13 +41,14 @@ function loadSkills(skillsDir) {
41
41
  const SECTION_ALIASES = {
42
42
  auth: "Authentication & permissions",
43
43
  authentication: "Authentication & permissions",
44
- "rate-limits": "Rate limits",
45
- "rate_limits": "Rate limits",
46
- ratelimits: "Rate limits",
47
- errors: "Error handling",
48
- "error-handling": "Error handling",
49
- pagination: "Pagination",
50
- recipes: "Recipes",
44
+ "rate-limits": "Reliability: rate limits, retries, idempotency",
45
+ "rate_limits": "Reliability: rate limits, retries, idempotency",
46
+ ratelimits: "Reliability: rate limits, retries, idempotency",
47
+ reliability: "Reliability: rate limits, retries, idempotency",
48
+ errors: "Error handling & troubleshooting",
49
+ "error-handling": "Error handling & troubleshooting",
50
+ pagination: "Query patterns & filtering",
51
+ recipes: "Common workflows (recipes)",
51
52
  gotchas: "Gotchas",
52
53
  webhooks: "Webhooks",
53
54
  overview: "Overview",
@@ -142,6 +143,10 @@ function skillSummary(content) {
142
143
  return "";
143
144
  }
144
145
  // ---------------------------------------------------------------------------
146
+ // Exports for testing
147
+ // ---------------------------------------------------------------------------
148
+ export { loadSkills, extractSection, findSkill, searchSkills, skillSummary };
149
+ // ---------------------------------------------------------------------------
145
150
  // MCP Server setup
146
151
  // ---------------------------------------------------------------------------
147
152
  const SKILLS_DIR = resolveSkillsDir();
@@ -253,7 +258,9 @@ async function main() {
253
258
  await server.connect(transport);
254
259
  process.stderr.write("[clawskills-mcp] Server running on stdio\n");
255
260
  }
256
- main().catch((err) => {
257
- process.stderr.write(`[clawskills-mcp] Fatal: ${err}\n`);
258
- process.exit(1);
259
- });
261
+ if (process.env.NODE_ENV !== "test") {
262
+ main().catch((err) => {
263
+ process.stderr.write(`[clawskills-mcp] Fatal: ${err}\n`);
264
+ process.exit(1);
265
+ });
266
+ }
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { loadSkills, extractSection, findSkill, searchSkills, skillSummary } from "./index.js";
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const REAL_SKILLS_DIR = path.resolve(__dirname, "../../skills");
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+ function makeTempSkillsDir(skills) {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawskills-test-"));
14
+ for (const [slug, content] of Object.entries(skills)) {
15
+ const skillDir = path.join(dir, slug);
16
+ fs.mkdirSync(skillDir, { recursive: true });
17
+ fs.writeFileSync(path.join(skillDir, "skill.md"), content, "utf-8");
18
+ }
19
+ return dir;
20
+ }
21
+ const SAMPLE_CONTENT = `# GitHub Skill
22
+
23
+ A skill doc for GitHub API.
24
+
25
+ ## Overview
26
+
27
+ GitHub is a code hosting platform for version control and collaboration.
28
+
29
+ ## Authentication & permissions
30
+
31
+ Use a Personal Access Token (PAT) or OAuth token.
32
+
33
+ Scopes: repo, read:user, etc.
34
+
35
+ ## Rate limits
36
+
37
+ - Authenticated: 5000 req/hour
38
+ - Unauthenticated: 60 req/hour
39
+
40
+ ## Recipes
41
+
42
+ ### Create an issue
43
+
44
+ POST /repos/{owner}/{repo}/issues
45
+ `;
46
+ let tempDir;
47
+ beforeAll(() => {
48
+ tempDir = makeTempSkillsDir({ github: SAMPLE_CONTENT, salesforce: "# Salesforce\nCRM platform.\n## Overview\nSalesforce is a CRM." });
49
+ });
50
+ afterAll(() => {
51
+ fs.rmSync(tempDir, { recursive: true, force: true });
52
+ });
53
+ // ---------------------------------------------------------------------------
54
+ // loadSkills
55
+ // ---------------------------------------------------------------------------
56
+ describe("loadSkills", () => {
57
+ it("loads skill files from a directory", () => {
58
+ const skills = loadSkills(tempDir);
59
+ expect(skills.size).toBe(2);
60
+ expect(skills.has("github")).toBe(true);
61
+ expect(skills.has("salesforce")).toBe(true);
62
+ });
63
+ it("returns empty map for missing directory", () => {
64
+ const skills = loadSkills("/nonexistent/path/that/does/not/exist");
65
+ expect(skills.size).toBe(0);
66
+ });
67
+ it("skips directories without skill.md", () => {
68
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawskills-test-noskill-"));
69
+ try {
70
+ fs.mkdirSync(path.join(dir, "nodoc"), { recursive: true });
71
+ const skills = loadSkills(dir);
72
+ expect(skills.size).toBe(0);
73
+ }
74
+ finally {
75
+ fs.rmSync(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+ });
79
+ // ---------------------------------------------------------------------------
80
+ // findSkill
81
+ // ---------------------------------------------------------------------------
82
+ describe("findSkill", () => {
83
+ let skills;
84
+ beforeAll(() => {
85
+ skills = loadSkills(tempDir);
86
+ });
87
+ it("returns key for exact match", () => {
88
+ expect(findSkill(skills, "github")).toBe("github");
89
+ });
90
+ it("is case-insensitive", () => {
91
+ expect(findSkill(skills, "GitHub")).toBe("github");
92
+ expect(findSkill(skills, "GITHUB")).toBe("github");
93
+ });
94
+ it("matches slug-normalized input (hub-spot → hubspot)", () => {
95
+ const m = new Map([["hubspot", "content"]]);
96
+ expect(findSkill(m, "hub-spot")).toBe("hubspot");
97
+ });
98
+ it("matches by prefix", () => {
99
+ expect(findSkill(skills, "git")).toBe("github");
100
+ });
101
+ it("returns undefined for no match", () => {
102
+ expect(findSkill(skills, "zzznomatch")).toBeUndefined();
103
+ });
104
+ });
105
+ // ---------------------------------------------------------------------------
106
+ // extractSection
107
+ // ---------------------------------------------------------------------------
108
+ describe("extractSection", () => {
109
+ it("resolves alias 'auth' to the correct header", () => {
110
+ const result = extractSection(SAMPLE_CONTENT, "auth");
111
+ expect(result).not.toBeNull();
112
+ expect(result).toContain("Personal Access Token");
113
+ });
114
+ it("finds section by direct header text", () => {
115
+ const result = extractSection(SAMPLE_CONTENT, "Rate limits");
116
+ expect(result).not.toBeNull();
117
+ expect(result).toContain("5000 req/hour");
118
+ });
119
+ it("returns null for missing section", () => {
120
+ const result = extractSection(SAMPLE_CONTENT, "nonexistent-section-xyz");
121
+ expect(result).toBeNull();
122
+ });
123
+ it("stops at the next ## header", () => {
124
+ const result = extractSection(SAMPLE_CONTENT, "Rate limits");
125
+ expect(result).not.toContain("POST /repos");
126
+ });
127
+ });
128
+ // ---------------------------------------------------------------------------
129
+ // searchSkills
130
+ // ---------------------------------------------------------------------------
131
+ describe("searchSkills", () => {
132
+ let skills;
133
+ beforeAll(() => {
134
+ skills = loadSkills(tempDir);
135
+ });
136
+ it("returns results for a matching query", () => {
137
+ const results = searchSkills(skills, "Personal Access Token");
138
+ expect(results.length).toBeGreaterThan(0);
139
+ expect(results[0].skill).toBe("github");
140
+ expect(results[0].excerpts.length).toBeGreaterThan(0);
141
+ });
142
+ it("is case-insensitive", () => {
143
+ const results = searchSkills(skills, "personal access token");
144
+ expect(results.length).toBeGreaterThan(0);
145
+ });
146
+ it("returns empty array on no match", () => {
147
+ const results = searchSkills(skills, "zzznomatchquery");
148
+ expect(results).toEqual([]);
149
+ });
150
+ it("caps excerpts at 5 per skill", () => {
151
+ // Create a skill with many matches
152
+ const manyMatches = Array.from({ length: 20 }, (_, i) => `line ${i}: keyword`).join("\n");
153
+ const m = new Map([["test", manyMatches]]);
154
+ const results = searchSkills(m, "keyword");
155
+ expect(results[0].excerpts.length).toBeLessThanOrEqual(5);
156
+ });
157
+ });
158
+ // ---------------------------------------------------------------------------
159
+ // skillSummary
160
+ // ---------------------------------------------------------------------------
161
+ describe("skillSummary", () => {
162
+ it("skips # headers and returns first non-empty line", () => {
163
+ const summary = skillSummary(SAMPLE_CONTENT);
164
+ expect(summary).toBe("A skill doc for GitHub API.");
165
+ });
166
+ it("handles content that starts with text immediately", () => {
167
+ const summary = skillSummary("Hello world\n## Section");
168
+ expect(summary).toBe("Hello world");
169
+ });
170
+ it("returns empty string for all-header content", () => {
171
+ const summary = skillSummary("# Title\n## Subtitle\n");
172
+ expect(summary).toBe("");
173
+ });
174
+ });
175
+ // ---------------------------------------------------------------------------
176
+ // Real skills validation
177
+ // Each skill added to the repo must pass these checks.
178
+ // ---------------------------------------------------------------------------
179
+ const REQUIRED_SECTIONS = [
180
+ "Authentication & permissions",
181
+ "Reliability: rate limits, retries, idempotency",
182
+ "Error handling & troubleshooting",
183
+ "Common workflows (recipes)",
184
+ ];
185
+ const KNOWN_SKILLS = [
186
+ "asana", "dynamics365", "figma", "github", "hubspot",
187
+ "jira", "monday", "salesforce", "servicenow", "zendesk",
188
+ "slack",
189
+ ];
190
+ describe("real skills", () => {
191
+ let skills;
192
+ beforeAll(() => {
193
+ skills = loadSkills(REAL_SKILLS_DIR);
194
+ });
195
+ it("loads all expected skills", () => {
196
+ for (const slug of KNOWN_SKILLS) {
197
+ expect(skills.has(slug), `Missing skill: ${slug}`).toBe(true);
198
+ }
199
+ });
200
+ it("every skill has a non-empty summary line", () => {
201
+ for (const [slug, content] of skills.entries()) {
202
+ const summary = skillSummary(content);
203
+ expect(summary, `${slug}: empty summary`).not.toBe("");
204
+ }
205
+ });
206
+ it("every skill contains the required sections", () => {
207
+ for (const [slug, content] of skills.entries()) {
208
+ for (const section of REQUIRED_SECTIONS) {
209
+ const extracted = extractSection(content, section);
210
+ expect(extracted, `${slug}: missing section "${section}"`).not.toBeNull();
211
+ }
212
+ }
213
+ });
214
+ it("every skill file is at least 5KB", () => {
215
+ for (const [slug, content] of skills.entries()) {
216
+ expect(content.length, `${slug}: suspiciously short skill file`).toBeGreaterThan(5000);
217
+ }
218
+ });
219
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawskills-mcp",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server exposing ClawSkills API integration skill docs to AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,9 @@
14
14
  "prepack": "rm -rf ./skills && cp -r ../skills ./skills",
15
15
  "build": "tsc",
16
16
  "start": "node dist/index.js",
17
- "dev": "ts-node src/index.ts"
17
+ "dev": "ts-node src/index.ts",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest"
18
20
  },
19
21
  "dependencies": {
20
22
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -22,9 +24,10 @@
22
24
  },
23
25
  "devDependencies": {
24
26
  "@types/node": "^22.0.0",
25
- "typescript": "^5.6.0"
27
+ "typescript": "^5.6.0",
28
+ "vitest": "^2.0.0"
26
29
  },
27
30
  "engines": {
28
- "node": ">=18"
31
+ "node": ">=22"
29
32
  }
30
33
  }
package/skills/INDEX.md CHANGED
@@ -20,6 +20,7 @@ A curated library of integration skill documents for the most common SaaS platfo
20
20
  | Asana | [asana/skill.md](./asana/skill.md) | Project Manager, RevOps | Task and project management with portfolio tracking |
21
21
  | GitHub | [github/skill.md](./github/skill.md) | Engineering, DevOps | Source control, PRs, CI/CD automation via REST + GraphQL |
22
22
  | Figma | [figma/skill.md](./figma/skill.md) | Design, Product, DevOps | Design file inspection, asset export, webhooks, design-to-dev handoff |
23
+ | Slack | [slack/skill.md](./slack/skill.md) | All teams | Real-time messaging, notifications, event-driven automation via Web API |
23
24
 
24
25
  ---
25
26
 
@@ -165,6 +166,20 @@ A curated library of integration skill documents for the most common SaaS platfo
165
166
 
166
167
  ---
167
168
 
169
+ ### Slack → [skill.md](./slack/skill.md)
170
+ 1. Post a rich Block Kit message to a channel
171
+ 2. Reply in a thread using `thread_ts`
172
+ 3. Send a direct message (open DM channel + postMessage)
173
+ 4. Look up a user by email address
174
+ 5. Update a message in place as status changes
175
+ 6. Upload a file using the two-step external upload API
176
+ 7. Add a reaction to a message
177
+ 8. List all channels and build a name → ID cache
178
+ 9. Read channel history incrementally using cursor pagination
179
+ 10. Receive, verify, and process Events API payloads
180
+
181
+ ---
182
+
168
183
  ## Common Cross-Tool Patterns
169
184
 
170
185
  These scenarios span multiple skill docs — reference both tools:
@@ -199,6 +214,7 @@ These scenarios span multiple skill docs — reference both tools:
199
214
  | Asana | Personal Access Token (PAT) | Bearer token | Inherits user permissions |
200
215
  | GitHub | Fine-grained PAT or GitHub App installation token | Bearer token | Per-resource permissions (contents, issues, pull_requests, etc.) |
201
216
  | Figma | Personal Access Token (PAT) or OAuth 2.0 | `X-Figma-Token` (PAT) / `Authorization: Bearer` (OAuth) | Granular scopes: `file_content:read`, `webhooks:write`, etc. |
217
+ | Slack | Bot token (OAuth 2.0 app install) | `xoxb-...` Bearer token | Per-method scopes: `chat:write`, `channels:read`, `users:read`, etc. |
202
218
 
203
219
  ---
204
220
 
@@ -216,10 +232,11 @@ These scenarios span multiple skill docs — reference both tools:
216
232
  | Asana | 1,500 req/min + 150 concurrent | 1 minute | `Retry-After` on 429 |
217
233
  | GitHub | PAT: 5,000/hr · GitHub App: 5k–12.5k/hr · Enterprise Cloud: 15,000/hr · Secondary: 900 pts/min, 80 create/min | 1 hour (primary) + sliding (secondary) | `x-ratelimit-remaining` / `x-ratelimit-reset`; 403 or 429 |
218
234
  | Figma | Starter: 10/min (T1), 25/min (T2) · Professional: 15/min (T1), 50/min (T2) · Org: 20/min (T1), 100/min (T2) | Per minute (leaky bucket) | `Retry-After` / `X-Figma-Rate-Limit-Type` / `X-Figma-Plan-Tier` |
235
+ | Slack | Tier 1: 1/min · Tier 2: 20/min · Tier 3: 50/min · Tier 4: 100/min · `chat.postMessage`: 1/sec/channel sub-limit | Per method per workspace | HTTP 429 + `Retry-After` header (seconds) |
219
236
 
220
237
  ---
221
238
 
222
- *Last updated: 2026-02-22 (GitHub + Figma skills added; all docs verified current) | See [ROADMAP.md](./ROADMAP.md) for versioning and governance details.*
239
+ *Last updated: 2026-02-26 (Slack skill added) | See [ROADMAP.md](./ROADMAP.md) for versioning and governance details.*
223
240
 
224
241
  ### API version reference (as of 2026-02-22)
225
242
 
@@ -235,3 +252,4 @@ These scenarios span multiple skill docs — reference both tools:
235
252
  | Asana | REST API 1.0 | No breaking changes; always use `opt_fields` |
236
253
  | GitHub | REST API + GraphQL v4 | Pin `X-GitHub-Api-Version: 2022-11-28`; fine-grained PATs recommended |
237
254
  | Figma | REST API v1 / Webhooks V2 | Path-based versioning (`/v1/`, `/v2/`); `files:read` scope deprecated — use granular scopes |
255
+ | Slack | Web API (no versioned path) | Always check `ok` field (HTTP 200 even on errors); `files.upload` deprecated — use `files.getUploadURLExternal` |
@@ -735,7 +735,7 @@ if current_version != cached_version:
735
735
 
736
736
  ---
737
737
 
738
- ## Rate limits, retries, idempotency
738
+ ## Reliability: rate limits, retries, idempotency
739
739
 
740
740
  ### Tier definitions (as of Nov 17, 2025)
741
741
 
@@ -794,7 +794,7 @@ def figma_request(url: str, token: str, params: dict = None, max_retries: int =
794
794
 
795
795
  ---
796
796
 
797
- ## Error handling
797
+ ## Error handling & troubleshooting
798
798
 
799
799
  | Code | When | Action |
800
800
  |------|------|--------|
@@ -0,0 +1,740 @@
1
+ # Slack Skill
2
+
3
+ > **Last validated:** 2026-02-26 | **API:** Slack Web API (no versioned URL path)
4
+ > **REST base URL:** `https://slack.com/api/`
5
+ > **Assumed product:** Slack (cloud). Slack Enterprise Grid adds org-level APIs but the core methods are identical.
6
+
7
+ ---
8
+
9
+ ## What this skill enables
10
+
11
+ - Send messages and rich Block Kit cards to any channel, DM, or thread from external systems.
12
+ - Build notification pipelines: alert on-call engineers, post deploy summaries, surface CRM events in team channels.
13
+ - Manage channels programmatically: create, archive, invite users, and sync membership from external directories.
14
+ - Respond to user actions in real time: slash commands, interactive buttons, modals (views), and workflow steps.
15
+ - Sync data bi-directionally: listen to Slack events (messages, reactions, channel membership) and reflect changes in external tools.
16
+ - Upload and share files: attach reports, screenshots, and exports directly in channels.
17
+ - Search and audit: query message history for compliance or reporting pipelines.
18
+
19
+ ---
20
+
21
+ ## Best-fit use cases
22
+
23
+ | Use case | Why it matters | Primary objects | Typical trigger | Success criteria |
24
+ |----------|---------------|-----------------|-----------------|-----------------|
25
+ | Post alert to on-call channel | Instant human awareness of incidents | Message, Channel | PagerDuty/Datadog webhook | Message posted with link to runbook within 2s |
26
+ | Deploy summary notification | Keep team informed without polling dashboards | Message, Block Kit | CI/CD pipeline completes | Formatted message with pass/fail + diff link |
27
+ | Sync CRM deal update to sales channel | Surface revenue moments to the team | Message, Thread | HubSpot/Salesforce webhook | Deal name, stage, and owner in message |
28
+ | Create ticket from Slack reaction | Lightweight triage without leaving Slack | Reaction event, Message | User adds 🎫 emoji to a message | Jira/Zendesk ticket created, permalink posted in thread |
29
+ | Slash command → workflow | Self-service automation from Slack UI | Slash command, Modal | User types `/create-ticket` | Modal opens, user fills form, ticket created |
30
+ | User lookup by email | Resolve an email address to a Slack user ID | User | Before sending a DM | `user_id` returned for `conversations.open` |
31
+ | Daily digest | Scheduled summary of CRM/ops data | Message, Scheduled post | Cron job | Summary message posted at 9 AM team timezone |
32
+ | Channel membership sync | Keep project channels aligned with HRIS/CRM | Channel, User | User onboarding/offboarding event | User invited or removed from correct channels |
33
+ | File report delivery | Share generated reports without email | File | Scheduled job or request | File uploaded, shared in channel with comment |
34
+ | Webhook event to external system | Mirror Slack activity to ops tools | Events API | Message posted, reaction added | External system receives and processes event within 3s |
35
+
36
+ ---
37
+
38
+ ## Key concepts & data model
39
+
40
+ ### Core objects
41
+
42
+ | Object | Description | Identified by |
43
+ |--------|-------------|---------------|
44
+ | **Workspace** | A Slack organization | `team_id` (e.g., `T01ABCDEF`) |
45
+ | **Channel** | Public channel, private channel, DM, or MPIM (group DM) | `channel_id` (e.g., `C01ABCDEF` public, `G01…` private, `D01…` DM) |
46
+ | **User** | A person in the workspace | `user_id` (e.g., `U01ABCDEF`) |
47
+ | **Bot** | A non-human entity representing a Slack app | `bot_id` (e.g., `B01ABCDEF`) |
48
+ | **Message** | A chat message in a channel or thread | `(channel_id, ts)` tuple — `ts` is a Unix timestamp string like `"1706200000.123456"` |
49
+ | **Thread** | A sub-conversation anchored to a message | Parent message's `ts` used as `thread_ts` |
50
+ | **File** | An uploaded file or snippet | `file_id` (e.g., `F01ABCDEF`) |
51
+ | **Reaction** | An emoji added to a message | `(name, user_id, item)` — `name` is the emoji name without colons |
52
+ | **App** | A Slack integration/bot | `app_id` (e.g., `A01ABCDEF`) |
53
+ | **View** | A modal or App Home surface | `view_id` returned by `views.open` |
54
+
55
+ ### Channel types and ID prefixes
56
+
57
+ | Prefix | Type | Notes |
58
+ |--------|------|-------|
59
+ | `C` | Public channel | Visible to all workspace members |
60
+ | `G` | Private channel / MPIM | Invite-only; group DMs also use `G` |
61
+ | `D` | Direct message | Between bot and one user |
62
+
63
+ > **Tip:** Always resolve channel names to IDs at startup using `conversations.list` and cache them. Names can change; IDs are stable.
64
+
65
+ ### Message timestamps (`ts`)
66
+
67
+ Slack uses `ts` (timestamp) as the primary message identifier — it is a string like `"1706200000.123456"`, not a number. Always store and compare as a string. The `ts` of the parent message is passed as `thread_ts` to post into a thread.
68
+
69
+ ### Relationships
70
+
71
+ ```
72
+ Workspace (team_id)
73
+ ├── Channel (C / G / D)
74
+ │ ├── Message (channel + ts)
75
+ │ │ ├── Thread replies (thread_ts = parent ts)
76
+ │ │ ├── Reactions
77
+ │ │ └── File attachments
78
+ │ └── Members (user_ids)
79
+ ├── User (U)
80
+ └── App / Bot (A / B)
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Authentication & permissions
86
+
87
+ ### Supported auth methods
88
+
89
+ | Method | Best for | Token format |
90
+ |--------|----------|-------------|
91
+ | **Bot token** | All server-to-server automation (recommended) | `xoxb-...` |
92
+ | **User token** | Acting as a specific user; required for some user-only methods | `xoxp-...` |
93
+ | **App-level token** | Socket Mode (WebSocket) connections only | `xapp-...` |
94
+ | **Incoming Webhook URL** | Simple one-way message posting with no SDK | Full URL, e.g. `https://hooks.slack.com/services/T.../B.../...` |
95
+
96
+ **Bot tokens are recommended** for all automation — they have stable identity, are scoped to the app, and do not expose any individual user's data.
97
+
98
+ ### Creating a Slack App and getting a bot token
99
+
100
+ 1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From Manifest** (or From Scratch).
101
+ 2. Under **OAuth & Permissions** → **Bot Token Scopes**, add the scopes your app needs (see table below).
102
+ 3. **Install App** to your workspace → copy the **Bot User OAuth Token** (`xoxb-...`).
103
+ 4. Store the token as an environment variable: `SLACK_BOT_TOKEN`.
104
+
105
+ ```bash
106
+ curl -s https://slack.com/api/auth.test \
107
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
108
+ -H "Content-Type: application/json"
109
+ # Response: {"ok": true, "team": "Acme Corp", "user": "my-bot", "team_id": "T01...", "user_id": "U01..."}
110
+ ```
111
+
112
+ ### Required scopes (bot token — least privilege)
113
+
114
+ | Scope | Purpose |
115
+ |-------|---------|
116
+ | `chat:write` | Post messages as the bot |
117
+ | `chat:write.public` | Post to public channels without joining first |
118
+ | `channels:read` | List public channels, get channel info |
119
+ | `channels:history` | Read messages in public channels the bot has joined |
120
+ | `groups:read` | List private channels the bot is a member of |
121
+ | `groups:history` | Read messages in private channels |
122
+ | `im:read` | List DMs the bot has open |
123
+ | `im:write` | Open DMs with users |
124
+ | `im:history` | Read DM messages |
125
+ | `users:read` | Get user info (name, display name, tz) |
126
+ | `users:read.email` | Look up users by email address |
127
+ | `files:write` | Upload files |
128
+ | `reactions:read` | Read reactions on messages |
129
+ | `reactions:write` | Add/remove reactions |
130
+ | `channels:join` | Join public channels programmatically |
131
+ | `channels:manage` | Create/archive public channels |
132
+ | `groups:write` | Create/archive private channels |
133
+ | `team:read` | Get workspace info |
134
+ | `search:read` | Search messages (user token only — not available to bots) |
135
+
136
+ > **Note:** `search:read` requires a **user token** (`xoxp-...`). There is no bot-accessible search API; use `conversations.history` + cursor pagination for bot-accessible message scanning.
137
+
138
+ ### Required headers (always include)
139
+
140
+ ```
141
+ Authorization: Bearer xoxb-your-token
142
+ Content-Type: application/json
143
+ ```
144
+
145
+ > **Legacy note:** Some Slack docs show `application/x-www-form-urlencoded` POST bodies. This still works but JSON (`application/json`) is the modern approach and is required for Block Kit payloads.
146
+
147
+ ### Token storage
148
+
149
+ - Store bot tokens in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault). Never commit to code.
150
+ - Bot tokens do not expire unless the app is uninstalled or the token is revoked. Implement health checks (call `auth.test` on startup) to detect revocation early.
151
+ - For multi-workspace (Slack Connect / distribution), use OAuth 2.0 and store one bot token per workspace in your database.
152
+
153
+ ---
154
+
155
+ ## Common workflows (recipes)
156
+
157
+ ### Recipe 1: Post a message to a channel
158
+
159
+ **Goal:** Send a formatted notification to a channel.
160
+
161
+ ```bash
162
+ curl -s -X POST https://slack.com/api/chat.postMessage \
163
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
164
+ -H "Content-Type: application/json" \
165
+ -d '{
166
+ "channel": "C01ABCDEF",
167
+ "text": "Deployment complete: *api-service v2.3.1* deployed to production.",
168
+ "blocks": [
169
+ {
170
+ "type": "section",
171
+ "text": {
172
+ "type": "mrkdwn",
173
+ "text": ":white_check_mark: *api-service v2.3.1* deployed to *production*\n*Duration:* 2m 14s | *By:* @janesmith"
174
+ }
175
+ },
176
+ {
177
+ "type": "actions",
178
+ "elements": [
179
+ {
180
+ "type": "button",
181
+ "text": { "type": "plain_text", "text": "View Logs" },
182
+ "url": "https://logs.example.com/deploy/1234",
183
+ "action_id": "view_logs"
184
+ }
185
+ ]
186
+ }
187
+ ]
188
+ }'
189
+ # Response: {"ok": true, "channel": "C01ABCDEF", "ts": "1706200000.123456", "message": {...}}
190
+ ```
191
+
192
+ **Always include both `text` and `blocks`.** The `text` field is the fallback for notifications and accessibility tools — it is displayed when blocks cannot be rendered.
193
+
194
+ **Edge cases:**
195
+ - Bot must be a member of the channel (or use the `chat:write.public` scope for public channels).
196
+ - If `channel` is a user ID (e.g., `U01...`), Slack will send a DM. Alternatively, use Recipe 3 to open a DM first.
197
+ - To post to a private channel, the bot must be invited first: `conversations.invite`.
198
+
199
+ ---
200
+
201
+ ### Recipe 2: Post a reply in a thread
202
+
203
+ **Goal:** Add a reply to an existing message thread.
204
+
205
+ ```bash
206
+ curl -s -X POST https://slack.com/api/chat.postMessage \
207
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
208
+ -H "Content-Type: application/json" \
209
+ -d '{
210
+ "channel": "C01ABCDEF",
211
+ "thread_ts": "1706200000.123456",
212
+ "text": "Incident resolved. Root cause: connection pool exhaustion. Post-mortem linked below.",
213
+ "reply_broadcast": false
214
+ }'
215
+ ```
216
+
217
+ **`reply_broadcast: true`** also sends the reply to the main channel (appears in both thread and channel).
218
+
219
+ ---
220
+
221
+ ### Recipe 3: Send a direct message to a user
222
+
223
+ **Goal:** Open a DM channel with a user and post a message.
224
+
225
+ ```bash
226
+ # Step 1 — open a DM channel (idempotent — returns existing channel if open)
227
+ DM_CHANNEL=$(curl -s -X POST https://slack.com/api/conversations.open \
228
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
229
+ -H "Content-Type: application/json" \
230
+ -d '{"users": "U01ABCDEF"}' \
231
+ | jq -r '.channel.id')
232
+
233
+ # Step 2 — post message
234
+ curl -s -X POST https://slack.com/api/chat.postMessage \
235
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
236
+ -H "Content-Type: application/json" \
237
+ -d "{
238
+ \"channel\": \"$DM_CHANNEL\",
239
+ \"text\": \"Hi! Your Salesforce deal has moved to Proposal stage.\"
240
+ }"
241
+ ```
242
+
243
+ ---
244
+
245
+ ### Recipe 4: Look up a user by email
246
+
247
+ **Goal:** Find a user's Slack ID from their email address (e.g., from a CRM record).
248
+
249
+ ```bash
250
+ curl -s -G "https://slack.com/api/users.lookupByEmail" \
251
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
252
+ --data-urlencode "email=janesmith@example.com"
253
+ # Response: {"ok": true, "user": {"id": "U01ABCDEF", "name": "janesmith", "tz": "America/New_York", ...}}
254
+ ```
255
+
256
+ Requires `users:read.email` scope. Store the resolved `user_id` — do not call this on every request.
257
+
258
+ ---
259
+
260
+ ### Recipe 5: Update or delete a message
261
+
262
+ **Goal:** Edit a previously posted message (e.g., update a status message as a deployment progresses).
263
+
264
+ ```bash
265
+ # Update
266
+ curl -s -X POST https://slack.com/api/chat.update \
267
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
268
+ -H "Content-Type: application/json" \
269
+ -d '{
270
+ "channel": "C01ABCDEF",
271
+ "ts": "1706200000.123456",
272
+ "text": "Deployment FAILED: api-service v2.3.1",
273
+ "blocks": [
274
+ {
275
+ "type": "section",
276
+ "text": { "type": "mrkdwn", "text": ":x: *Deployment FAILED* — api-service v2.3.1\nSee <https://logs.example.com/1234|logs> for details." }
277
+ }
278
+ ]
279
+ }'
280
+
281
+ # Delete
282
+ curl -s -X POST https://slack.com/api/chat.delete \
283
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
284
+ -H "Content-Type: application/json" \
285
+ -d '{"channel": "C01ABCDEF", "ts": "1706200000.123456"}'
286
+ ```
287
+
288
+ **Pattern:** Post a "starting…" message, save the returned `ts`, then update it in place as the operation progresses. This avoids flooding the channel with multiple messages.
289
+
290
+ ---
291
+
292
+ ### Recipe 6: Upload a file to a channel
293
+
294
+ **Goal:** Share a generated report or screenshot in a channel. Use the two-step upload API (recommended since 2023).
295
+
296
+ ```python
297
+ import requests, os
298
+
299
+ token = os.environ["SLACK_BOT_TOKEN"]
300
+ headers = {"Authorization": f"Bearer {token}"}
301
+
302
+ # Step 1 — get an upload URL
303
+ resp = requests.get(
304
+ "https://slack.com/api/files.getUploadURLExternal",
305
+ headers=headers,
306
+ params={"filename": "report.csv", "length": os.path.getsize("report.csv")}
307
+ )
308
+ data = resp.json()
309
+ upload_url = data["upload_url"]
310
+ file_id = data["file_id"]
311
+
312
+ # Step 2 — upload the file content to the pre-signed URL
313
+ with open("report.csv", "rb") as f:
314
+ requests.post(upload_url, data=f)
315
+
316
+ # Step 3 — complete the upload and share to channel
317
+ requests.post(
318
+ "https://slack.com/api/files.completeUploadExternal",
319
+ headers=headers,
320
+ json={
321
+ "files": [{"id": file_id, "title": "Weekly Report"}],
322
+ "channel_id": "C01ABCDEF",
323
+ "initial_comment": "Here's the weekly ops report."
324
+ }
325
+ )
326
+ ```
327
+
328
+ > **Avoid** `files.upload` (the old single-call method) — it is deprecated and has a 1 GB limit vs. much larger limits on the new API.
329
+
330
+ ---
331
+
332
+ ### Recipe 7: Add a reaction to a message
333
+
334
+ **Goal:** React to a message programmatically (e.g., acknowledge receipt by adding ✅).
335
+
336
+ ```bash
337
+ curl -s -X POST https://slack.com/api/reactions.add \
338
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
339
+ -H "Content-Type: application/json" \
340
+ -d '{
341
+ "channel": "C01ABCDEF",
342
+ "timestamp": "1706200000.123456",
343
+ "name": "white_check_mark"
344
+ }'
345
+ ```
346
+
347
+ Emoji `name` is the text between colons (e.g., `:white_check_mark:` → `"white_check_mark"`). Custom emoji are referenced by their workspace name.
348
+
349
+ ---
350
+
351
+ ### Recipe 8: List channels and resolve names to IDs
352
+
353
+ **Goal:** Build and cache a channel name → ID map at startup.
354
+
355
+ ```python
356
+ import requests
357
+
358
+ def get_channel_map(token: str) -> dict[str, str]:
359
+ """Returns {channel_name: channel_id} for all public channels."""
360
+ headers = {"Authorization": f"Bearer {token}"}
361
+ channel_map = {}
362
+ cursor = None
363
+
364
+ while True:
365
+ params = {"limit": 200, "exclude_archived": True, "types": "public_channel"}
366
+ if cursor:
367
+ params["cursor"] = cursor
368
+
369
+ resp = requests.get(
370
+ "https://slack.com/api/conversations.list",
371
+ headers=headers,
372
+ params=params
373
+ ).json()
374
+
375
+ if not resp["ok"]:
376
+ raise Exception(f"Slack error: {resp['error']}")
377
+
378
+ for ch in resp["channels"]:
379
+ channel_map[ch["name"]] = ch["id"]
380
+
381
+ cursor = resp.get("response_metadata", {}).get("next_cursor")
382
+ if not cursor:
383
+ break
384
+
385
+ return channel_map
386
+
387
+ # Usage
388
+ CHANNELS = get_channel_map(os.environ["SLACK_BOT_TOKEN"])
389
+ post_to = CHANNELS["engineering-deploys"]
390
+ ```
391
+
392
+ ---
393
+
394
+ ### Recipe 9: Read channel history (incremental sync)
395
+
396
+ **Goal:** Fetch messages posted since the last sync run.
397
+
398
+ ```python
399
+ import requests, time
400
+
401
+ def fetch_messages_since(token: str, channel_id: str, oldest_ts: str) -> list:
402
+ headers = {"Authorization": f"Bearer {token}"}
403
+ messages = []
404
+ cursor = None
405
+
406
+ while True:
407
+ params = {"channel": channel_id, "oldest": oldest_ts, "limit": 200}
408
+ if cursor:
409
+ params["cursor"] = cursor
410
+
411
+ resp = requests.get(
412
+ "https://slack.com/api/conversations.history",
413
+ headers=headers,
414
+ params=params
415
+ ).json()
416
+
417
+ if not resp["ok"]:
418
+ raise Exception(f"Slack error: {resp['error']}")
419
+
420
+ messages.extend(resp["messages"])
421
+
422
+ if not resp.get("has_more"):
423
+ break
424
+ cursor = resp["response_metadata"]["next_cursor"]
425
+
426
+ return messages
427
+
428
+ # Store the latest ts after each run as the next oldest
429
+ latest_ts = str(time.time() - 3600) # last hour on first run
430
+ messages = fetch_messages_since(token, "C01ABCDEF", latest_ts)
431
+ next_run_ts = messages[0]["ts"] if messages else latest_ts
432
+ ```
433
+
434
+ ---
435
+
436
+ ### Recipe 10: Receive and verify Events API payloads
437
+
438
+ **Goal:** Handle real-time Slack events (message posted, reaction added, member joined, etc.) in your webhook endpoint.
439
+
440
+ **App setup:** In your Slack App config → **Event Subscriptions** → enable, set Request URL to your endpoint, subscribe to bot events (e.g., `message.channels`, `reaction_added`).
441
+
442
+ **URL verification (one-time, on first save):**
443
+ ```python
444
+ from flask import Flask, request, jsonify
445
+
446
+ app = Flask(__name__)
447
+
448
+ @app.route("/slack/events", methods=["POST"])
449
+ def slack_events():
450
+ body = request.json
451
+
452
+ # Slack URL verification challenge
453
+ if body.get("type") == "url_verification":
454
+ return jsonify({"challenge": body["challenge"]})
455
+
456
+ # Verify signature before processing
457
+ if not verify_slack_signature(request):
458
+ return "", 403
459
+
460
+ event = body.get("event", {})
461
+ # Handle asynchronously — must return 200 within 3 seconds
462
+ handle_event_async(event)
463
+ return "", 200
464
+ ```
465
+
466
+ **Signature verification (required — never skip this):**
467
+ ```python
468
+ import hmac, hashlib, time
469
+
470
+ SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
471
+
472
+ def verify_slack_signature(request) -> bool:
473
+ timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
474
+ sig_header = request.headers.get("X-Slack-Signature", "")
475
+
476
+ # Reject requests older than 5 minutes (replay attack prevention)
477
+ if abs(time.time() - int(timestamp)) > 300:
478
+ return False
479
+
480
+ base_string = f"v0:{timestamp}:{request.get_data(as_text=True)}"
481
+ expected = "v0=" + hmac.new(
482
+ SLACK_SIGNING_SECRET.encode(),
483
+ base_string.encode(),
484
+ hashlib.sha256
485
+ ).hexdigest()
486
+
487
+ return hmac.compare_digest(expected, sig_header)
488
+ ```
489
+
490
+ **Critical:** Respond with HTTP 200 within **3 seconds**. If processing takes longer, return 200 immediately and process asynchronously (queue the event). Slack retries failed deliveries 3 times.
491
+
492
+ ---
493
+
494
+ ### Recipe 11: Invite a user to a channel
495
+
496
+ **Goal:** Add a user to a private channel when they join a project.
497
+
498
+ ```bash
499
+ curl -s -X POST https://slack.com/api/conversations.invite \
500
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
501
+ -H "Content-Type: application/json" \
502
+ -d '{
503
+ "channel": "G01ABCDEF",
504
+ "users": "U01ABCDEF,U02ABCDEF"
505
+ }'
506
+ # Response: {"ok": true, "channel": {...}}
507
+ # Error if user is already a member: {"ok": false, "error": "already_in_channel"}
508
+ ```
509
+
510
+ Multiple users can be invited in one call by comma-separating `users`.
511
+
512
+ ---
513
+
514
+ ## Query patterns & filtering
515
+
516
+ ### Pagination
517
+
518
+ All list endpoints use **cursor-based pagination**. Never use `page`/`offset` (not supported on most Slack endpoints).
519
+
520
+ ```python
521
+ def paginate_slack(token: str, method: str, params: dict) -> list:
522
+ """Generic cursor-based paginator for Slack list methods."""
523
+ headers = {"Authorization": f"Bearer {token}"}
524
+ results = []
525
+ cursor = None
526
+ list_key = None # auto-detected
527
+
528
+ while True:
529
+ p = {**params, "limit": 200}
530
+ if cursor:
531
+ p["cursor"] = cursor
532
+
533
+ resp = requests.get(
534
+ f"https://slack.com/api/{method}",
535
+ headers=headers,
536
+ params=p
537
+ ).json()
538
+
539
+ if not resp["ok"]:
540
+ raise Exception(f"Slack error {method}: {resp['error']}")
541
+
542
+ # Detect the list key (channels, members, users, messages, etc.)
543
+ if list_key is None:
544
+ list_key = next((k for k in resp if isinstance(resp[k], list) and k != "warnings"), None)
545
+ if list_key:
546
+ results.extend(resp[list_key])
547
+
548
+ cursor = resp.get("response_metadata", {}).get("next_cursor")
549
+ if not cursor:
550
+ break
551
+
552
+ return results
553
+ ```
554
+
555
+ ### Filtering messages
556
+
557
+ - **By time range:** Use `oldest` and `latest` parameters (Unix timestamp strings) on `conversations.history`.
558
+ - **By thread:** Use `conversations.replies` with `ts` of the parent message.
559
+ - **Bot vs human messages:** Check `subtype` field — bot messages have `subtype: "bot_message"` or `bot_id` present.
560
+ - **Ignore system messages:** Filter out messages with `subtype` set (e.g., `channel_join`, `channel_leave`).
561
+
562
+ ### Message deduplication
563
+
564
+ Use `(channel_id, ts)` as a composite key — `ts` is unique within a channel. For Events API, Slack may retry the same event; use the event's `event_id` as an idempotency key:
565
+
566
+ ```python
567
+ processed_events = set() # use Redis in production
568
+
569
+ def handle_event_async(event_wrapper: dict):
570
+ event_id = event_wrapper.get("event_id")
571
+ if event_id in processed_events:
572
+ return # already handled
573
+ processed_events.add(event_id)
574
+ # ... process event
575
+ ```
576
+
577
+ ---
578
+
579
+ ## Reliability: rate limits, retries, idempotency
580
+
581
+ ### Rate limits (verified 2026-02-26)
582
+
583
+ Slack uses a **tiered** rate limit system, applied per workspace per method:
584
+
585
+ | Tier | Limit | Examples |
586
+ |------|-------|---------|
587
+ | **Tier 1** | 1+ req/min | `conversations.create`, `admin.*` |
588
+ | **Tier 2** | 20+ req/min | `auth.test`, `conversations.info`, `files.info` |
589
+ | **Tier 3** | 50+ req/min | `conversations.history`, `conversations.list`, `users.info`, `reactions.add` |
590
+ | **Tier 4** | 100+ req/min | `chat.postMessage`, `chat.update`, `users.list` |
591
+ | **Special** | 1 req/sec per channel | `chat.postMessage` per-channel sub-limit (regardless of Tier 4) |
592
+
593
+ When rate limited, Slack returns **HTTP 429** with a `Retry-After` header (seconds to wait).
594
+
595
+ > **Practical limit for `chat.postMessage`:** Even though it is Tier 4 (100/min workspace-wide), there is a per-channel limit of ~1 req/sec. For burst messaging to the same channel, add a 1-second delay between calls or use `chat.scheduleMessage`.
596
+
597
+ ### Retry strategy
598
+
599
+ ```python
600
+ import time, requests
601
+
602
+ def slack_api(method: str, token: str, **kwargs) -> dict:
603
+ url = f"https://slack.com/api/{method}"
604
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
605
+
606
+ for attempt in range(5):
607
+ resp = requests.post(url, headers=headers, json=kwargs)
608
+
609
+ if resp.status_code == 429:
610
+ retry_after = int(resp.headers.get("Retry-After", 5))
611
+ time.sleep(retry_after)
612
+ continue
613
+
614
+ if resp.status_code >= 500:
615
+ time.sleep(2 ** attempt)
616
+ continue
617
+
618
+ data = resp.json()
619
+ if not data["ok"]:
620
+ if data["error"] == "ratelimited":
621
+ time.sleep(int(resp.headers.get("Retry-After", 5)))
622
+ continue
623
+ raise Exception(f"Slack API error: {data['error']}")
624
+
625
+ return data
626
+
627
+ raise Exception("Max retries exceeded")
628
+ ```
629
+
630
+ > **Note:** Slack always returns HTTP 200, even for application-level errors. Always check `data["ok"]` — not just the HTTP status code.
631
+
632
+ ### Idempotency patterns
633
+
634
+ - **`chat.postMessage`:** Not natively idempotent. To avoid duplicate messages, use an external idempotency key: check whether a message with your known content/ts already exists in the channel before posting, or store the returned `ts` in your database.
635
+ - **`conversations.invite`:** Already idempotent — returns `already_in_channel` error (not a failure) if user is a member.
636
+ - **`reactions.add`:** Returns `already_reacted` if reaction already exists — safe to ignore.
637
+ - **Events API:** Use `event_id` as an idempotency key in Redis/database to deduplicate retried events.
638
+
639
+ ---
640
+
641
+ ## Error handling & troubleshooting
642
+
643
+ ### All Slack errors follow this pattern
644
+
645
+ ```json
646
+ { "ok": false, "error": "channel_not_found" }
647
+ ```
648
+
649
+ HTTP status is always 200. Parse the response body to detect errors.
650
+
651
+ ### Common errors
652
+
653
+ | Error string | Meaning | Fix |
654
+ |-------------|---------|-----|
655
+ | `not_in_channel` | Bot is not a member of the channel | Invite the bot or use `chat:write.public` scope |
656
+ | `channel_not_found` | Channel ID is wrong or bot can't see it | Verify channel ID; check bot is in workspace |
657
+ | `invalid_auth` | Token is invalid or revoked | Re-generate token; check env var |
658
+ | `missing_scope` | Bot token lacks required scope | Add scope in App config → reinstall app |
659
+ | `ratelimited` | Rate limit hit | Respect `Retry-After` header |
660
+ | `already_in_channel` | User already a channel member | Ignore — treat as success |
661
+ | `already_reacted` | Reaction already exists | Ignore — treat as success |
662
+ | `cant_invite_self` | Trying to invite the bot to a channel it's in | Ignore |
663
+ | `msg_too_long` | Message text exceeds 40,000 chars | Split into multiple messages |
664
+ | `no_text` | Empty `text` and no `blocks` | Always provide `text` as fallback |
665
+ | `restricted_action` | Workspace admin has blocked this action | Contact workspace admin |
666
+ | `is_archived` | Channel is archived | Unarchive or use a different channel |
667
+ | `thread_not_found` | `thread_ts` doesn't match any message | Verify `ts` is stored as a string, not float |
668
+ | `ekm_access_denied` | Enterprise Key Management restriction | Use an org-approved token |
669
+
670
+ ### "If you see X, do Y" playbook
671
+
672
+ - **Bot posts in the wrong channel / no message appears:** Verify the `channel` value is a channel ID (`C...`) not a name. Channel names are not accepted by `chat.postMessage`.
673
+ - **Messages appear in the channel but with broken formatting:** You passed `text` with `*bold*` or `_italic_` — use `mrkdwn` format (Slack's dialect), not standard Markdown. Key differences: `*bold*`, `_italic_`, `~strike~`, `<URL|link text>`, `<@U01...>` mentions.
674
+ - **Events API URL fails verification:** Your endpoint didn't return `{"challenge": ...}` within 3 seconds, or returned incorrect content. Ensure JSON response with `Content-Type: application/json`.
675
+ - **Events arrive but signature verification fails:** Make sure you're reading the raw request body (not parsed JSON) for HMAC calculation. Some frameworks re-encode the body — use the raw bytes.
676
+ - **`files.upload` returns `ok: false`:** The old upload method is deprecated. Switch to `files.getUploadURLExternal` + `files.completeUploadExternal`.
677
+ - **`chat.update` returns `cant_update_message`:** Only the bot that originally posted the message can update it. You cannot update messages posted by other bots or users.
678
+ - **Thread replies not appearing in thread:** You passed `thread_ts` correctly but used a different channel ID. The `channel` and `thread_ts` must both match the original message.
679
+
680
+ ### Debug logging
681
+
682
+ Log: API method, channel/user ID, response `ok` field, `error` field (if any), HTTP status, `Retry-After` (on 429).
683
+ Do NOT log: token values, full message text (may contain PII), file contents.
684
+
685
+ ---
686
+
687
+ ## Security, privacy, compliance
688
+
689
+ - **Least privilege:** Request only the scopes your app needs. Slack's permission model is per-scope, not per-channel — a `channels:history` scope grants access to all public channels the bot is in.
690
+ - **Token storage:** Never expose bot tokens in client-side code, logs, or URLs. Store in environment variables or a secrets manager. Bot tokens do not expire but can be revoked — implement `auth.test` health checks.
691
+ - **Signing secret:** Always verify `X-Slack-Signature` on all incoming webhooks and Events API payloads. Do not process unsigned requests.
692
+ - **DMs and private channels:** Private messages are sensitive. Minimize bot access to DMs and private channels; request only the scopes needed. Slack Enterprise Grid adds Data Loss Prevention (DLP) controls.
693
+ - **User data (PII):** `users.list` and `users.info` return display names, real names, and email addresses. Handle this data per your privacy policy — do not store more than necessary.
694
+ - **Message content:** Slack messages can contain PII, credentials, or confidential data. Treat message content as sensitive; do not log full message text in production.
695
+ - **Audit logs:** Slack Enterprise Grid provides an Audit Logs API (`audit.logs.v1`) for compliance logging. Standard Slack does not expose per-workspace audit logs via API.
696
+ - **Bot impersonation:** Never use `as_user: true` with a user token to post as a human user in automated flows — this is misleading and may violate your workspace's acceptable use policy.
697
+ - **GDPR:** Slack supports data export requests. If a user requests deletion, messages they sent cannot be deleted via API by bots (only the user or workspace admin can delete messages). Plan for this in your data retention policy.
698
+
699
+ ---
700
+
701
+ ## Testing checklist
702
+
703
+ - [ ] **Auth test:** `GET https://slack.com/api/auth.test` → `ok: true`, bot name and team visible.
704
+ - [ ] **Post message test:** `chat.postMessage` to a test channel → message appears with correct formatting; both `text` and `blocks` render correctly.
705
+ - [ ] **Thread test:** Reply to the posted message using `thread_ts` → reply appears in thread, not in main channel.
706
+ - [ ] **DM test:** `conversations.open` with a test user ID → `chat.postMessage` to returned DM channel → message arrives.
707
+ - [ ] **User lookup test:** `users.lookupByEmail` with a valid email → returns correct `user_id`.
708
+ - [ ] **Channel list test:** `conversations.list` with pagination → all channels returned; cursor exhausted.
709
+ - [ ] **History test:** `conversations.history` with `oldest` param → only messages after cutoff returned.
710
+ - [ ] **File upload test:** Full three-step upload flow → file appears in channel with comment.
711
+ - [ ] **Reaction test:** `reactions.add` → emoji visible on message; calling again returns `already_reacted` (not an error).
712
+ - [ ] **Rate limit test:** Send >1 req/sec to the same channel → 429 received → `Retry-After` respected → retry succeeds.
713
+ - [ ] **Events API test:** Post a message in a subscribed channel → event received at your endpoint within 3s → `event_id` logged; posting same message again doesn't reprocess.
714
+ - [ ] **Signature verification test:** Send request with invalid signature → 403 returned.
715
+ - [ ] **Negative tests:**
716
+ - Bot not in channel → `not_in_channel`
717
+ - Invalid token → `invalid_auth`
718
+ - Non-existent channel ID → `channel_not_found`
719
+ - `thread_ts` stored as float → `thread_not_found` (must be string)
720
+ - Missing `text` field with blocks → `no_text`
721
+
722
+ ---
723
+
724
+ ## Sources
725
+
726
+ - Slack Web API methods: https://api.slack.com/methods
727
+ - Authentication & token types: https://api.slack.com/authentication/token-types
728
+ - OAuth scopes: https://api.slack.com/scopes
729
+ - Block Kit (rich message formatting): https://api.slack.com/block-kit
730
+ - Block Kit Builder (interactive): https://app.slack.com/block-kit-builder
731
+ - Events API: https://api.slack.com/events-api
732
+ - Signing secrets & request verification: https://api.slack.com/authentication/verifying-requests-from-slack
733
+ - Rate limits: https://api.slack.com/docs/rate-limits
734
+ - File upload (new API): https://api.slack.com/methods/files.getUploadURLExternal
735
+ - Conversations API: https://api.slack.com/docs/conversations-api
736
+ - Pagination: https://api.slack.com/docs/pagination
737
+ - Incoming Webhooks: https://api.slack.com/messaging/webhooks
738
+ - Socket Mode: https://api.slack.com/apis/socket-mode
739
+ - Enterprise Grid Audit Logs API: https://api.slack.com/admins/audit-logs
740
+ - mrkdwn formatting reference: https://api.slack.com/reference/surfaces/formatting