Runtype
GuidesGuide

How to put your agent in Slack without building a Slack app from scratch

Minimum scopes for a Slack bot, the 3-second acknowledgement, threading rules, approval buttons, identity mapping to your users, and multi-workspace installs.

Last updated 6 min read

Register one Slack app, subscribe to app_mention and message.im, verify the request signature, and return HTTP 200 within three seconds. Run the agent in a background worker and post the answer with thread_ts set. The parts that take a fortnight are identity mapping and multi-workspace installation, not the bot.

The situation

The estimate is two hours because the first version really does take two hours: create an app, paste a token, wire a webhook, watch it reply. Then the reply lands at the bottom of the channel instead of in the thread. Then the same answer arrives three times because the model call took eleven seconds and Slack gave up waiting and redelivered. Then someone in a different workspace installs it and it answers with another company's data, because the token lived in an environment variable.

The remaining work is a list of small, unrelated obligations: request signing, replay windows, event deduplication, a scope set that survives review, an interactivity endpoint that answers in a different three-second budget, and the question of which account in your own system a Slack user id belongs to. This page walks that list in order. It is one channel out of the set covered in shipping an agent to production.

How do I put my AI agent into Slack

Eight steps. Two are configuration, four are the request path, and the last two are where the estimate usually breaks.

1. Pick Socket Mode or HTTP before writing anything

Socket Mode opens an outbound WebSocket with an app-level token (xapp-), so Slack never has to reach your network. It suits an internal bot and local development. HTTP delivery posts events to a request URL you host, which is what a distributed app needs, and Slack still does not allow Socket Mode apps in the public Slack Marketplace (as of September 2026). Socket Mode also assumes a process that stays connected, which fits poorly with per-request serverless functions.

2. Write the manifest, and keep the scopes small

A manifest is faster than the settings UI and reviewable in a pull request. This is the minimum for a bot that answers mentions in channels and direct messages:

display_information:
  name: Support Assistant
features:
  bot_user:
    display_name: support-assistant
oauth_config:
  scopes:
    bot:
      - app_mentions:read # receive @mentions of the bot
      - chat:write # post messages as the bot
      - im:history # read direct messages sent to the bot
      - im:write # open a DM with a user
      - users:read # resolve a Slack user id to a profile
settings:
  event_subscriptions:
    request_url: https://example.com/slack/events
    bot_events:
      - app_mention
      - message.im
  interactivity:
    is_enabled: true
    request_url: https://example.com/slack/interactivity

channels:history is absent on purpose. It is the scope that lets you read messages nobody addressed to the bot, and step 5 covers when to add it. users:read.email is a separate, sensitive scope; do not request it until step 6 says you need it.

3. Verify the signature, then acknowledge inside three seconds

Slack signs each request. Compute an HMAC-SHA256 over the string v0:{timestamp}:{raw body} with your signing secret, compare it to the X-Slack-Signature header in constant time, and reject a X-Slack-Request-Timestamp older than five minutes so a captured request cannot be replayed. The body must be the raw bytes, which means the JSON parser has to run after verification.

Then acknowledge. Slack expects HTTP 200 within three seconds and otherwise retries the delivery up to three times, nearly immediately, then after a minute, then after five minutes. Each redelivery carries the same event_id, an X-Slack-Retry-Num of 1, 2 or 3, and an X-Slack-Retry-Reason. An agent turn takes seconds to minutes, so the acknowledgement cannot wait for it:

app.post('/slack/events', async (req, res) => {
  if (!verifySlackSignature(req)) return res.sendStatus(401)
  const body = req.body

  if (body.type === 'url_verification') return res.send(body.challenge)

  res.sendStatus(200) // before any model call

  const event = body.event
  if (event.bot_id || event.subtype === 'bot_message') return
  if (!(await claimEventId(body.event_id))) return // retries reuse the id
  await queue.publish({ teamId: body.team_id, event })
})

claimEventId is an insert into a table with a unique constraint on the event id, or a Redis SET NX. Without it, one slow turn becomes several identical answers.

4. Reply in the thread, and say something early

The worker posts with chat.postMessage and thread_ts: event.thread_ts ?? event.ts. That expression is the whole threading rule: reply inside an existing thread when the message was already in one, otherwise start a thread on the message that triggered you. Omitting it puts the answer at the bottom of a busy channel, detached from the question.

Latency is visible in Slack in a way it is not in a chat widget. Post a short placeholder immediately and replace it with chat.update once the turn finishes, so the channel shows progress rather than silence. Long answers need chunking: Slack truncates a text field past 40,000 characters and advises staying under 4,000 for best results, a message carries at most 50 Block Kit blocks, and a section block's text tops out at 3,000 characters. Posting is rate limited to roughly one message per second per channel, so a loop that emits one message per tool call will be throttled.

5. Decide what the agent is allowed to read

Three entry points, three different context rules:

Entry pointEventContext to loadScope needed
@bot in a channelapp_mentionThe mention, plus the thread via conversations.repliesapp_mentions:read, plus a history scope for the thread read
Direct message to the botmessage.imThe DM history with that one personim:history
Follow-up in a bot threadmessage.channelsThe thread, filtered to threads the bot has posted inchannels:history

The third row is the trap. A reply in a thread without a new mention does not produce an app_mention event, so a conversational back-and-forth requires subscribing to channel messages and discarding almost all of them. Weigh that against asking users to mention the bot each turn, which is unusual in a chat product and normal in Slack.

Everything you load from that thread reaches the model, including messages written by people who are not talking to the bot and links pasted by anyone in the workspace. Treat that text as untrusted input to any tool the agent can call.

6. Map the Slack user to an account in your system

Slack gives you two identifiers per event: team_id (T…), which is the workspace, and user_id (U…), which is unique within that workspace. Neither is a user in your product. The same person in two workspaces has two Slack ids, and an Enterprise Grid install adds an enterprise_id above the team.

Use team_id as the tenant key and require an explicit link for the person. On first contact, post an ephemeral message with a one-time link into your app; the user signs in there, and you store the (team_id, slack_user_id) pair against their account.

Reading the Slack profile email with users:read.email and matching on it is the shortcut, and it fails on shared workspaces, aliases and anyone whose Slack email differs from their login. Until the link exists, run the agent with workspace-level access only and have per-user tools refuse rather than guess. The general shape of this problem across channels is in running one agent on several channels.

7. Collect approvals with Block Kit, and check who clicked

An agent that issues refunds needs a human decision, and Slack is a place where that decision is cheap to collect. Attach an actions block with approve and deny buttons, each carrying the pending call id in value:

{
  "blocks": [
    { "type": "section", "text": { "type": "mrkdwn", "text": "*Refund $240* on order 10231" } },
    {
      "type": "actions",
      "elements": [
        { "type": "button", "action_id": "approve", "style": "primary", "text": { "type": "plain_text", "text": "Approve" }, "value": "call_9f21" },
        { "type": "button", "action_id": "deny", "style": "danger", "text": { "type": "plain_text", "text": "Deny" }, "value": "call_9f21" }
      ]
    }
  ]
}

Button clicks arrive at the interactivity request URL as a form-encoded payload, under the same three-second budget, and you update the message later through the response_url in that payload, which accepts up to five responses within 30 minutes of the interaction. Anyone who can see the message can press the button, so the handler must check the clicking user.id against your own permission model before resuming the run, and the pending approval must expire on its own. What to gate and how to record the decision is in adding a human approval step.

8. Install into other workspaces without forking anything

A single bot token in an environment variable works until the second workspace installs. Replace it with the OAuth v2 flow: redirect to Slack's authorize URL, exchange the returned code at oauth.v2.access, and store the bot token keyed by team_id, along with enterprise_id and is_enterprise_install when they are present. Every outbound API call then loads the token for the workspace the event came from.

Handle removal as carefully as installation. Subscribe to app_uninstalled and tokens_revoked, delete the stored token, and stop any schedules that post into that workspace. Public listing adds a Slack review that checks your scopes, your privacy policy and your data handling, so requesting a scope you do not use is a review comment rather than a detail nobody notices.

Where this gets easier

Runtype ships Slack as a surface rather than a project: the app manifest, signature verification, the acknowledgement and background execution, threading, and the mapping from a Slack workspace and user to a tenant and an end user are already built, so what remains is deciding what the agent does in a channel. The same agent definition also answers over web chat, SMS, iMessage, an MCP server and a REST API, and an approval gated on a tool renders as buttons in Slack rather than as a second integration (approvals on messaging surfaces).

Frequently asked questions

Do I need Socket Mode or a public request URL?
Socket Mode if the app is internal and you would rather not expose an endpoint, or while developing on a laptop. A public HTTPS request URL if the app will be installed by other workspaces, since Socket Mode holds an outbound WebSocket from a long-lived process and Slack does not allow Socket Mode apps in the public Slack Marketplace (as of September 2026). Both delivery modes give you the same events and the same three-second budget.
How do I stop the bot from answering its own messages?
Drop any event carrying a bot_id, a bot_message subtype, or your own bot user id before you queue work. Two agents in the same channel will otherwise reply to each other until a rate limit stops them. Deduplicate on event_id as well, because Slack redelivers the same event after a slow acknowledgement and the retry carries the original id.
Should the agent read the whole channel?
Start with the mention and the thread it sits in, fetched with conversations.replies. Subscribing to every message in every channel needs a history scope, produces an event for each message a human types, and puts content the agent was never asked about into your logs. Widen the read scope when a specific behavior requires it, not by default.