Slack Apps skill for Claude Code
Bolt apps that ack in time and ask for few scopes. Claude builds Slack integrations from a committed app manifest, acknowledges every command, action and modal submission inside the 3-second window before doing real work, and keeps scopes minimal. It knows Block Kit limits, trigger_id expiry, event retry headers and rate-limit tiers, and wires OAuth installation stores keyed by team for apps installed beyond one workspace.
- name
slack-apps - size246 lines · ≈2,700 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds Slack apps with Bolt for JavaScript or Python, covering app manifests, scopes, Events API, slash commands, shortcuts, interactive Block Kit messages and modals, request signature verification, OAuth multi-workspace installs, Socket Mode vs. HTTP, rate limits, and serverless deployment. Use when creating a Slack bot or integration, adding a slash command or modal, handling Slack events or button clicks, distributing an app to other workspaces, or fixing timeouts and dispatch_failed errors. Try asking
- Build a /ticket slash command that opens a modal and posts the result to the channel
- Our Slack button handler shows operation_timeout — fix it
- Make our internal Slack bot installable in customer workspaces with OAuth
Use it when
- Creating a Slack bot, slash command, shortcut, or App Home tab
- Handling Slack events, button clicks, or modal submissions
- Distributing a Slack app to multiple workspaces
- Debugging dispatch_failed, not_in_channel, or 3-second timeouts
Skip it when
- You are building a Discord bot — use discord-bots instead
- You only need to post alerts to an incoming webhook URL from CI — use github-actions or your alerting tool instead
How to check the result
Every command, button and modal path works in a dev workspace with an artificial delay after ack, and a tampered or stale request is rejected with 401 in HTTP mode.
The common mistake it prevents
Making a database or API call before ack(), which passes locally and then times out in production with dispatch_failed.
Done well means: every request is acknowledged within 3 seconds, every inbound HTTP request
is signature-verified, scopes are the minimum the features need, messages carry a plain
text fallback, and the app’s configuration lives in a versioned manifest rather than
hand-clicked settings.
Ground rules
- Tokens and secrets from env/secret store only:
SLACK_BOT_TOKEN(xoxb-),SLACK_SIGNING_SECRET,SLACK_APP_TOKEN(xapp-, Socket Mode),SLACK_CLIENT_ID,SLACK_CLIENT_SECRET,SLACK_STATE_SECRET. Never log tokens or full payloads with user content in production. ack()first, work later. Slash commands, actions, shortcuts, and view submissions fail with “operation_timeout”/“dispatch_failed” after 3 seconds without an ack.- Use Bolt (
@slack/boltv4 orslack_boltfor Python). It verifies signatures, handles retries of the Web API, and routes payloads. Hand-roll only when Bolt cannot run there. - Request the fewest scopes. Each added scope forces existing workspaces to reinstall.
Choose the shape
| Decision | Default | Switch when |
|---|---|---|
| Transport | Socket Mode for local dev and internal apps behind a firewall | HTTP Request URLs for Marketplace/distributed apps and serverless |
| Distribution | Single workspace with a bot token | OAuth + installation store when other workspaces install it |
| Hosting | Long-running Node/Python process | Lambda/Cloud Functions with processBeforeResponse (JS) or lazy listeners (Python) |
| UI entry point | Slash command or message shortcut | App Home tab for persistent dashboards; workflow steps for no-code users |
| Long work (>3 s) | ack() then post/update a message, or queue a job and use response_url | — |
Workflow
- [ ] 1. Write the app manifest (scopes, commands, events, interactivity)
- [ ] 2. Create/update the app from the manifest; install to a dev workspace
- [ ] 3. Scaffold Bolt with env-based config
- [ ] 4. Implement listeners: ack fast, then act
- [ ] 5. Build Block Kit UI and validate in Block Kit Builder
- [ ] 6. Add OAuth installation store if distributing
- [ ] 7. Handle retries, rate limits, and errors
- [ ] 8. Verify in Slack: each command, button, modal, and failure path
1. Manifest (source of truth, commit it)
display_information:
name: Triage Bot
features:
bot_user: { display_name: triage, always_online: false }
slash_commands:
- command: /ticket
description: File a ticket
usage_hint: "[title]"
should_escape: false
oauth_config:
scopes:
bot: [commands, chat:write, app_mentions:read]
settings:
event_subscriptions:
bot_events: [app_mention]
interactivity: { is_enabled: true }
socket_mode_enabled: true
org_deploy_enabled: false
token_rotation_enabled: false
For HTTP mode set socket_mode_enabled: false and add request_url under
event_subscriptions, interactivity, and each slash command
(https://<host>/slack/events with Bolt’s default receiver).
Scope cheatsheet: post messages chat:write (plus chat:write.public to post in channels
without joining); read mentions app_mentions:read; read channel messages
channels:history (public) / groups:history (private) only if truly needed; user lookup
users:read (users:read.email for emails).
3–4. Bolt for JavaScript
import { App, LogLevel } from "@slack/bolt";
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: process.env.SLACK_SOCKET_MODE === "true",
appToken: process.env.SLACK_APP_TOKEN,
logLevel: LogLevel.INFO,
});
app.command("/ticket", async ({ ack, body, client }) => {
await ack(); // within 3 s, before any API call
await client.views.open({ // trigger_id also expires in 3 s
trigger_id: body.trigger_id,
view: {
type: "modal",
callback_id: "ticket_submit",
private_metadata: JSON.stringify({ channel: body.channel_id }),
title: { type: "plain_text", text: "New ticket" },
submit: { type: "plain_text", text: "Create" },
blocks: [{
type: "input", block_id: "title",
label: { type: "plain_text", text: "Title" },
element: { type: "plain_text_input", action_id: "value", initial_value: body.text },
}],
},
});
});
app.view("ticket_submit", async ({ ack, view, body, client }) => {
const title = view.state.values.title.value.value?.trim();
if (!title || title.length < 5) {
await ack({ response_action: "errors", errors: { title: "At least 5 characters" } });
return;
}
await ack();
const { channel } = JSON.parse(view.private_metadata);
const ticket = await createTicket({ title, reporter: body.user.id });
await client.chat.postMessage({
channel,
text: `Ticket #${ticket.id} created: ${title}`, // notification + accessibility fallback
blocks: ticketBlocks(ticket),
});
});
app.action("ticket_close", async ({ ack, body, action, client }) => {
await ack();
// Authorize: the clicker is body.user.id; check permissions in your system, not Slack's UI.
// ...close ticket, then client.chat.update({ channel, ts, text, blocks })
});
app.event("app_mention", async ({ event, say }) => {
await say({ text: "On it.", thread_ts: event.thread_ts ?? event.ts });
});
app.error(async (err) => { console.error(err.code, err.message); });
await app.start(Number(process.env.PORT ?? 3000));
Python (slack_bolt) mirrors this: @app.command("/ticket"), ack(), client.views_open(...),
SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start().
5. Block Kit rules
- Max 50 blocks per message, 100 per modal or Home tab; section text max 3,000 chars;
button
valuemax 2,000 chars. Paginate long lists. - Always send top-level
textwithblocks; it is what notifications and screen readers use. - Use
action_id/block_id/callback_idas stable routing keys; put record IDs invalueorprivate_metadata, never trust them without a server-side permission check. - Update the message after an action (
chat.updateorrespond({ replace_original: true })) so a button cannot be clicked twice into duplicate work. - Prototype layouts in Block Kit Builder, then paste the JSON into code.
6. OAuth for distributed apps
const app = new App({
signingSecret: process.env.SLACK_SIGNING_SECRET,
clientId: process.env.SLACK_CLIENT_ID,
clientSecret: process.env.SLACK_CLIENT_SECRET,
stateSecret: process.env.SLACK_STATE_SECRET,
scopes: ["commands", "chat:write", "app_mentions:read"],
installationStore: {
storeInstallation: async (i) => db.saveInstallation(i.isEnterpriseInstall ? i.enterprise!.id : i.team!.id, i),
fetchInstallation: async (q) => db.getInstallation(q.isEnterpriseInstall ? q.enterpriseId! : q.teamId!),
deleteInstallation: async (q) => db.deleteInstallation(q.isEnterpriseInstall ? q.enterpriseId! : q.teamId!),
},
});
Bolt serves /slack/install and /slack/oauth_redirect. Encrypt stored tokens at rest.
Handle app_uninstalled and tokens_revoked events by deleting the installation. Key all
data by team_id (and enterprise_id for Enterprise Grid org installs).
7. Retries, rate limits, errors
- Events API retries up to 3 times when you do not return 2xx in 3 seconds, with
X-Slack-Retry-NumandX-Slack-Retry-Reasonheaders. Dedupe onevent_id. - Web API methods are tiered (roughly Tier 1: 1/min up to Tier 4: 100+/min);
chat.postMessageallows about 1 message per second per channel with short bursts. On 429, waitRetry-Afterseconds; Bolt’sWebClientretries automatically but a fan-out loop still needs a queue. - Slack tightened
conversations.history/conversations.replieslimits in 2025 for commercially distributed apps that are not Marketplace-approved. Check the current rate limit docs before designing features that read history. - Error strings to recognize:
not_in_channel(invite the bot or usechat:write.public),missing_scope(add scope, reinstall),channel_not_found(private channel or wrong workspace token),invalid_auth(wrong token for this team).
Serverless
- JS on Lambda:
AwsLambdaReceiverandprocessBeforeResponse: true; the handler must finish within 3 seconds, so hand long work to a queue. - Python: lazy listeners (
app.command("/x")(ack=ack_fn, lazy=[work_fn])) withprocess_before_response=True.
Hand-rolled signature verification (non-Bolt receivers only)
import crypto from "node:crypto";
function verifySlack(rawBody: string, ts: string, sig: string, secret: string) {
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
const mac = "v0=" + crypto.createHmac("sha256", secret).update(`v0:${ts}:${rawBody}`).digest("hex");
return mac.length === sig.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig));
}
// headers: X-Slack-Request-Timestamp, X-Slack-Signature; body must be the raw bytes
Gotchas
- Calling an API before
ack(): works on a warm dev laptop, times out in production. - Opening a modal from a queued job:
trigger_idalready expired. Open first, fill later withviews.update. - Bot not in the channel:
not_in_channel. Messages to users go to their DM withchannel: userId. say()inside an action from a modal: there is no channel context; useclient.chat.postMessagewith a stored channel.- Treating
user.nameas stable or unique: useuser.id. - Enabling socket mode and HTTP URLs at once and wondering which receives events.
- Changing scopes in code without updating the manifest and reinstalling.
Verification
- Run in Socket Mode against a dev workspace; exercise each command, shortcut, button, and modal path, including validation errors.
- HTTP mode: a request with a modified body or stale timestamp gets 401.
- Add an artificial 2-second delay before work (after
ack) and confirm no timeout. - Fire the same event twice (or replay with the retry header) and confirm one side effect.
- Install to a second workspace (if distributed) and confirm data isolation by
team_id.
Output contract
**App:** Triage Bot (manifest: slack/manifest.yaml)
**Transport:** Socket Mode (dev), HTTP /slack/events (prod)
**Scopes:** commands, chat:write, app_mentions:read
**Features:** /ticket → modal → channel message with Close button; @mention reply in thread
**Safety:** ack-first in all listeners, event_id dedupe, signature verification by Bolt
**Env:** SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, SLACK_APP_TOKEN
**Tested:** each listener in dev workspace, invalid modal input, timeout margin About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.