Slack Bot Integration

The Slack Bot integration lets a VM drive a real Slack bot: post as the bot, read history, react, and respond live to mentions and DMs — while the bot's tokens stay off the VM entirely. You bring your own Slack app; exe.dev holds its tokens server-side and injects them on the way to Slack.

This is the two-way sibling of the send-only Slack Integration. Use that one if all you need is "post a message to a channel."

How it works

Your VM talks to the integration hostname exactly as it would talk to slack.com:

curl -X POST https://mybot.int.exe.xyz/api/chat.postMessage --json '{"channel":"C0123","text":"hi"}'

exe.dev forwards /api/<method> calls to the Slack Web API with your bot token attached. Any Web API method works; what the bot may actually do is bounded by the scopes you granted your own app.

For receiving events (mentions, DMs), the integration supports Slack's Socket Mode: the VM calls one special method, apps.connections.open, and gets back a single-use ticketed wss:// URL. The VM connects to that URL directly — an outbound WebSocket, no inbound ports, no tokens — and events stream in over it.

Step 1: create your Slack app

Go to api.slack.com/apps → Create New App → From an app manifest, pick your workspace, and paste:

{
  "display_information": { "name": "my-exe-bot" },
  "features": {
    "bot_user": { "display_name": "my-exe-bot", "always_online": true }
  },
  "oauth_config": {
    "scopes": {
      "bot": ["chat:write", "app_mentions:read", "im:history"]
    }
  },
  "settings": {
    "event_subscriptions": {
      "bot_events": ["app_mention", "message.im"]
    },
    "socket_mode_enabled": true
  }
}

This is the minimal talk-to-able bot: it can post, and it hears mentions and DMs. Add scopes as your bot needs them (channels:history to read channels, reactions:write to react, files:read to download private files, files:write to upload, ...). Each event subscription needs its matching read scope or Slack won't deliver it.

Then collect two tokens:

  1. Bot token (xoxb-…): Install App to your workspace, then copy the Bot User OAuth Token from OAuth & Permissions.
  2. App-level token (xapp-…), only needed for receiving events: Basic Information → App-Level Tokens → Generate, with the connections:write scope.

Finally, invite the bot to a channel: /invite @my-exe-bot.

Step 2: create the integration

On the Integrations page, click the Slack Bot tile and paste the tokens — the Test button verifies them against Slack and fills in the bot's name. Or via SSH, pass - to read each token from stdin. At the interactive prompt, each - asks for its token with hidden input:

exe.dev ▶ integrations add slack --name mybot --bot-token=- --app-token=-
Bot token (xoxb-...):
App-level token (xapp-...):
exe.dev ▶ integrations attach mybot vm:my-vm

Scripted (ssh exe.dev integrations add ...), stdin carries one token per line in the same order as the corresponding flags:

$ printf '%s\n%s\n' "$SLACK_BOT_TOKEN" "$SLACK_APP_TOKEN" | \
    ssh exe.dev integrations add slack --name mybot --bot-token=- --app-token=-

The --app-token is optional; without it the bot can call the Web API but not receive events.

Step 3: use it from the VM

The examples below use the integration hostname mybot.int.exe.xyz — substitute the exact URL printed when you created the integration (it varies by integration name and environment).

Post as the bot:

curl -X POST https://mybot.int.exe.xyz/api/chat.postMessage \
  --json '{"channel":"C0123456789","text":"hello from my VM"}'

Sanity-check the token routing:

curl -X POST https://mybot.int.exe.xyz/api/auth.test

Receive events over Socket Mode. Standard Slack SDKs work once pointed at the integration host; here is the bare-bones version with just websockets:

import asyncio, json, urllib.request, websockets

GW = "https://mybot.int.exe.xyz/api/"  # your integration URL (as printed on creation) + /api/

def gw_post(method, payload=None):
    data = json.dumps(payload).encode() if payload is not None else b""
    req = urllib.request.Request(GW + method, data=data,
        headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)

async def main():
    url = gw_post("apps.connections.open")["url"]   # ticketed wss URL
    async with websockets.connect(url) as ws:
        async for raw in ws:
            msg = json.loads(raw)
            if "envelope_id" in msg:                 # ack everything
                await ws.send(json.dumps({"envelope_id": msg["envelope_id"]}))
            ev = msg.get("payload", {}).get("event", {})
            if ev.get("type") == "app_mention":
                gw_post("chat.postMessage",
                        {"channel": ev["channel"], "text": "you rang?"})

asyncio.run(main())

Mention the bot in Slack and it answers — and at no point did an xoxb- or xapp- token exist on the VM. The only credential the VM ever holds is the single-use WebSocket ticket.

Download private files

With the files:read bot scope, Slack file objects contain url_private and url_private_download. For a documented /files-pri/... URL, replace only the URL's hostname with your integration hostname and reuse its path and query. Do not add an Authorization header: exe.dev supplies the bot token only to Slack's pinned file origin. Query bytes are preserved exactly; the path is safety-validated and encoded once by the gateway.

For example, if Slack returns:

https://files.slack.com/files-pri/T0123-F0456/report.txt

fetch the same path through the integration:

curl https://mybot.int.exe.xyz/files-pri/T0123-F0456/report.txt \
  -o report.txt

GET, HEAD, byte ranges, and standard file validators are supported for /files-pri/... and thumbnail /files-tmb/... paths. If Slack answers with a redirect to its signed CDN URL, the gateway returns that redirect to the VM (status and Location) instead of following it server-side, so use a client that follows redirects (curl -L). The VM follows the short-lived signed URL with the URL's own token; the durable bot token is never sent on that second hop. Redirects are only passed through after validation (HTTPS, an approved Slack CDN host, no userinfo, default port); anything failing validation fails closed. The signed Location is never written to exe.dev logs.

curl -L https://mybot.int.exe.xyz/files-pri/T0123-F0456/report.txt \
  -o report.txt

If you add files:read or files:write after installing the app, reinstall it to the workspace and recheck the existing stored credential first. If Slack rotated the token, update the integration through the secure credential intake; never transfer a replacement token onto the VM or through chat.

Notes

  • Slack rate-limits the Web API per method. When Slack says slow down, exe.dev honors the Retry-After and rejects further calls to that method locally until the window passes; other methods are unaffected.
  • apps.connections.open without a stored app token returns 409 with the fix (integrations edit mybot --app-token=-).
  • Slack asks Socket Mode clients to reconnect periodically; SDKs handle this automatically. If you hand-roll, just re-open a fresh ticket when the socket closes.
  • Slack allows at most 10 concurrent Socket Mode connections per app.
  • Rotating tokens (e.g. after adding scopes, which requires reinstalling the app): integrations edit mybot --bot-token=- --app-token=-.