Integrations No. 420

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.

  • nameslack-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.

~/.claude/skills/slack-apps/SKILL.md

--- name: slack-apps description: Builds Slack apps with Bolt for JavaScript or Python, covering app manifests, scopes, Events API, slash commands, shortcuts, interactive… license: MIT ---

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/bolt v4 or slack_bolt for 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

DecisionDefaultSwitch when
TransportSocket Mode for local dev and internal apps behind a firewallHTTP Request URLs for Marketplace/distributed apps and serverless
DistributionSingle workspace with a bot tokenOAuth + installation store when other workspaces install it
HostingLong-running Node/Python processLambda/Cloud Functions with processBeforeResponse (JS) or lazy listeners (Python)
UI entry pointSlash command or message shortcutApp 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 value max 2,000 chars. Paginate long lists.
  • Always send top-level text with blocks; it is what notifications and screen readers use.
  • Use action_id/block_id/callback_id as stable routing keys; put record IDs in value or private_metadata, never trust them without a server-side permission check.
  • Update the message after an action (chat.update or respond({ 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-Num and X-Slack-Retry-Reason headers. Dedupe on event_id.
  • Web API methods are tiered (roughly Tier 1: 1/min up to Tier 4: 100+/min); chat.postMessage allows about 1 message per second per channel with short bursts. On 429, wait Retry-After seconds; Bolt’s WebClient retries automatically but a fan-out loop still needs a queue.
  • Slack tightened conversations.history/conversations.replies limits 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 use chat: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: AwsLambdaReceiver and processBeforeResponse: 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])) with process_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_id already expired. Open first, fill later with views.update.
  • Bot not in the channel: not_in_channel. Messages to users go to their DM with channel: userId.
  • say() inside an action from a modal: there is no channel context; use client.chat.postMessage with a stored channel.
  • Treating user.name as stable or unique: use user.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

  1. Run in Socket Mode against a dev workspace; exercise each command, shortcut, button, and modal path, including validation errors.
  2. HTTP mode: a request with a modified body or stale timestamp gets 401.
  3. Add an artificial 2-second delay before work (after ack) and confirm no timeout.
  4. Fire the same event twice (or replay with the retry header) and confirm one side effect.
  5. 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.

esc