Slack adapter
Task
Run the Ethos gateway against a Slack workspace in a way that survives real traffic: install a socket-mode app with the right scopes, route app_mention and DMs to per-channel sessions, restrict who can talk to the bot, stay under Slack's send rate, run a second workspace's app from the same host, and recognise the failure modes when they arrive.
Result
- A Slack app runs in Socket Mode — no public URL, no TLS, no event-subscription endpoint.
- The bot replies to
@mentionsin channels and to DMs; it never reaches for messages it was not addressed in. - Channels, threads, and DMs each get their own session keyed
slack:<channel-id>/slack:<thread-ts>/slack:<channel-id>(DMs are channels of typeim). - Outbound text is split into 3,000-character chunks, deduped over a 30-second window, and edited in place when the agent streams.
- A second workspace's app runs from a sibling
~/.ethos/without interfering with the first.
Prereqs
ethos chatworks locally with a configured LLM provider.- A Slack app created at
https://api.slack.com/apps→ Create New App → From scratch. - The app installed to the target workspace; the bot user added to at least one channel.
- Three secrets in hand: bot token (
xoxb-…), app-level token (xapp-…) withconnections:write, and the signing secret.
Source
extensions/platform-slack/src/index.ts—SlackAdapter(Bolt SDK insocketMode: true, chunking, edit-in-place).extensions/platform-slack/src/validate.ts—auth.test-based bot-token validation called by setup.extensions/gateway/src/index.ts— routing, slash commands, dedup, allowlist enforcement.packages/safety/channel/src/channel-filter.ts— sender allowlist, mention gate, DM policy.apps/ethos/src/commands/gateway.ts— adapter wiring (new SlackAdapter({ botToken, appToken, signingSecret })).
Steps
1. Create the Slack app
In https://api.slack.com/apps:
- Create New App → From scratch. Pick a name and the target workspace.
- Under OAuth & Permissions → Bot Token Scopes, add:
app_mentions:read— receive mention events.channels:history— read messages in public channels the bot is in.groups:history— same for private channels.im:history— read DMs to the bot.im:read— list DM channels.im:write— open DM channels for outbound messages.chat:write— post messages and edit them.users:read— resolve user ids when audit-logging.files:read— download user-attached files fromurl_private_download. Without it, Slack returns an HTML page in place of the file bytes; see Receive files via Slack.reactions:write— set and clear the 👀 receipt reaction on inbound messages (best-effort; missing scope just drops the reaction).
- Under Socket Mode → Enable Socket Mode.
- Under Basic Information → App-Level Tokens → Generate Token and Scopes, add the
connections:writescope. Copy thexapp-…token. - Under Event Subscriptions → Enable Events, subscribe to bot events:
app_mentionmessage.im
- Under Install App, install to the workspace. Copy the Bot User OAuth Token (
xoxb-…). - Under Basic Information, copy the Signing Secret.
2. Wire the secrets
# ~/.ethos/config.yaml
slackBotToken: "xoxb-…"
slackAppToken: "xapp-…"
slackSigningSecret: "abc123…"
ethos gateway setup validates the bot token against https://slack.com/api/auth.test and writes all three fields. The gateway requires all three to be set before it loads the adapter (see apps/ethos/src/commands/gateway.ts).
3. Start the gateway
ethos gateway start
Expected boot lines include ⚡️ Bolt app started. Socket Mode means no reverse proxy, no domain, no inbound port. For production with the web dashboard alongside, use ethos run-all under PM2 — see Deploy in production. For just the Slack gateway by itself under launchd / systemd / pm2, see Run Ethos as a daemon.
4. Understand routing
SlackAdapter.start() subscribes to two events (extensions/platform-slack/src/index.ts):
message— fires for every DM the bot can see. The adapter accepts messages withchannel_type === 'im'; everything else is ignored at this layer.app_mention— fires for@mentionsin channels. The adapter strips the<@UXXXXXXXX>token before forwarding.
| Source | chatId | isDm | isGroupMention | Effective session key |
|---|---|---|---|---|
Channel @mention | channel id C… | false | true | slack:<channel-id> |
Thread (channel mention with thread_ts) | channel id | false | true | slack:<channel-id> (replies go to the thread) |
| DM | channel id D… (type im) | true | false | slack:<channel-id> |
When the inbound message has a thread_ts or the replyToId is set, the adapter passes thread_ts to chat.postMessage so the reply lands in the same thread. New top-level mentions start a new thread when the agent's reply spans multiple chunks.
The adapter skips messages with any subtype (bot messages, edits, joins) — Bolt surfaces those as message events too.
4a. Plugin commands
Plugin commands registered via registerSlashCommand() are dispatched through the gateway's text-matching pipeline — the same path as built-in / commands.
Unlike Telegram and Discord, Slack requires manual slash command registration in the Slack app dashboard (api.slack.com). The gateway logs the list of commands that need registration at startup so operators can sync them. See Register plugin slash commands for the full walkthrough.
5. Restrict who can talk to the bot
Configure under channelFilter.slack (packages/safety/channel/src/channel-filter.ts):
channelFilter:
slack:
ownerUserId: "U01ABCDEFGH" # Slack user id — always allowed
recipientAllowlist:
- "U02ABCDEFGH" # extra user ids
- "C03ABCDEFGH" # or whole channel ids
dmPolicy: pairing # pairing | allowlist | queue | reject | silent-drop
contextVisibility: allowlist # strip quoted text from non-allowlisted senders
Field meanings:
ownerUserId— bypasses every gate, including the channel mention gate.recipientAllowlist— Slack user ids and channel ids that are also allowed.dmPolicy— what happens when a non-allowlisted user DMs the bot.pairing(default) replies with a one-time code;allowlistsilently drops; the rest match the Telegram adapter.contextVisibility: allowlist— strips quoted Slack-thread content from non-allowlisted senders before the agent sees it.
From inside ethos chat:
/communications
/allow 7H3K-9XQ2
/deny slack U02ABCDEFGH
6. Drive multi-workspace deployments
A Slack app is installed per workspace. Each installation issues its own bot token. To run the same Ethos app across several workspaces:
- Run several gateways with separate
~/.ethos/directories:
HOME=/srv/ethos-workspace-a ethos gateway start &
HOME=/srv/ethos-workspace-b ethos gateway start &
Each HOME carries its own slackBotToken, slackAppToken, slackSigningSecret, SQLite store, logs, and pairing database. The bots stay isolated; sessions never cross.
- Or run one gateway against one workspace and add Telegram or Discord tokens alongside. The same
MessageDedupCache(30s TTL, keyed by(sessionId, sha256(content))) suppresses duplicate outbound text on the same session across every adapter.
A single Slack adapter instance binds to one bot token. Multi-tenant Slack distribution (one app, many workspaces, OAuth per workspace) is not yet wired through apps/ethos/src/commands/gateway.ts.
Verify
Token works.
curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" -H "Content-Type: application/json" \
https://slack.com/api/auth.test | jq .ok
Returns true along with the team name. false or invalid_auth means the bot token is wrong; reinstall the app.
Gateway sees Slack.
Foreground-start the gateway and confirm ⚡️ Bolt app started. The adapter's health() calls auth.test and reports ok plus latency.
Mention round-trips in a channel.
In a channel the bot is a member of, post @Ethos ping. Reply arrives within a few seconds.
DM round-trips.
DM the bot ping. Reply arrives. If you set dmPolicy: pairing, the first DM from a non-allowlisted user gets a pairing code instead.
Thread routing.
Reply to one of the bot's messages inside the thread. The bot's next reply lands in the same thread (via thread_ts).
Allowlist enforcement.
Have a non-allowlisted account @mention the bot. With the default config, the message is silently dropped; ethos audit | tail shows channel.allowlist.blocked or channel.mention_gate.
Dedup is active.
Send the same prompt twice within 30 seconds. The bot answers once if generated text is identical.
Troubleshoot
⚡️ Bolt app started prints but the bot ignores every message.
Either app_mention / message.im are not subscribed in Event Subscriptions, or Socket Mode is off. Both are required.
Bot replies in a channel it was just invited to but not in another.
Slack scopes are per-channel for channels:history and groups:history. Invite the bot to each channel you want it active in. app_mention works regardless of channel membership, but reading thread history does not.
HTTP 429 / ratelimited from chat.postMessage.
Slack's tier-3 rate limit is ~50 calls per minute per workspace. Streaming edits stay under this because reflowChunks edits in place. Sustained 429s mean two gateways share a bot token or a personality is auto-replying. Check ethos errors and the running gateway count.
not_in_channel from chat.postMessage.
The bot user is not a member of the target channel. Invite the bot to the channel.
missing_scope errors.
The bot lacks a scope the gateway needs. Reinstall the app after adding the scope under OAuth & Permissions; reinstallation issues a new bot token, so update slackBotToken in ~/.ethos/config.yaml after.
Replies arrive split into multiple messages.
Expected. The adapter caps outbound text at 3,000 characters per chat.postMessage to leave headroom under Slack's 4,000-character block limit; chunkText splits at newlines (>60% of the limit). Streamed edits re-flow — first chunks are edited, extras are appended, trailing chunks are deleted.
Replies in a thread show up as top-level channel messages.
The inbound message had no thread_ts. Reply to one of the bot's existing messages in the thread to seed it, or include thread_ts when calling the gateway directly.
A message sent twice on purpose was answered only once.
The outbound MessageDedupCache suppresses identical (sessionId, content) within 30 seconds. Change one character, wait 30 seconds, or set ETHOS_DEDUP_LEGACY=1 to disable.
Pairing code expired.
Codes have a TTL in packages/safety/channel/src/pairing-store.ts. If the DM author waited too long, they need to DM again. Owners can /communications approve-all to approve every pending sender.
Socket Mode reconnects every few minutes.
Slack rotates the WebSocket; Bolt reconnects automatically. Persistent flapping points at a network or proxy issue between the host and wss.slack.com. The signing secret is only checked for HTTP event endpoints, so it does not cause socket disconnects.
Errors you may see
| Code | Surface | Cause | Fix |
|---|---|---|---|
channel.allowlist.blocked | gateway audit | Non-allowlisted sender in a channel, or DM under dmPolicy: silent-drop. | Add the user id to recipientAllowlist or trigger /allow <code>. |
channel.mention_gate | gateway audit | Allowlisted sender posted in a channel without @mentioning the bot. | Mention the bot or reply in-thread. |
channel.pairing.sent | gateway audit | First DM from a non-allowlisted user; pairing code emitted. | Owner runs /allow <code> to approve. |
channel.context_stripped | gateway audit | Quoted thread content from a non-allowlisted user was removed before the turn. | Expected when contextVisibility: allowlist. |
invalid_auth | adapter health | Bot token revoked or wrong. | Reinstall the app, copy the new xoxb-… token. |
not_in_channel | delivery result | Bot is not a member of the target channel. | Invite the bot. |
missing_scope | delivery result | App lacks a Bot Token Scope. | Add the scope, reinstall, update the token. |
See also
- Telegram adapter — long-polling adapter sharing the same gateway boundary.
- Discord adapter — guild and DM routing.
- Run Ethos as a daemon —
launchd,systemd,pm2. - Glossary —
gateway,session,audience boundary.