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:
- Bot token (
xoxb-…): Install App to your workspace, then copy the Bot User OAuth Token from OAuth & Permissions. - App-level token (
xapp-…), only needed for receiving events: Basic Information → App-Level Tokens → Generate, with theconnections:writescope.
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-Afterand rejects further calls to that method locally until the window passes; other methods are unaffected. apps.connections.openwithout 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=-.