Date: 2026-04-15 Status: Accepted Owner: @tomkis
ADR-016 established that messenger integrations are handled by the API Server via a channels abstraction. This ADR specifies the Slack-specific integration design.
Key constraints:
- The API Server runs behind a VPN — Slack cannot POST to it directly
- Multiple users share a single Slack workspace and need access to different instances
- Users must be authenticated (linked to Keycloak identity per ADR-015)
- A single Slack app serves the entire Humr installation
The Slack app uses Socket Mode instead of HTTP request URLs. The API Server connects to Slack via WebSocket using an App-Level Token (xapp-...), receiving all events and interactions over the socket. No inbound network access required.
A /humr login slash command initiates a Keycloak OAuth flow:
- User types
/humr login - Bot replies with an ephemeral message containing a Keycloak login URL
- User clicks, authenticates via Keycloak
- Keycloak redirects to the API Server callback (user is on VPN, so this works)
- API Server stores
slack_user_id ↔ keycloak_identitymapping
All subsequent interactions require a linked identity. Unlinked users receive an ephemeral prompt to /humr login first.
Channel membership is the coarse-grained gate — users must be in the channel and have linked their identity to see and read bot interactions.
Per-instance allowed users is the fine-grained gate — each instance can optionally declare a list of users who are allowed to interact with it. Users not on the list can still read the channel and see bot responses, but the bot ignores their messages (or replies with an ephemeral "no access" message).
This gives instance owners control over who can trigger agent work while keeping the channel visible to the broader team for transparency. For example, an instance owner can restrict interaction to themselves and one collaborator, while the rest of the channel observes.
When a user sends a message in a channel:
- Bot checks which instances the user has access to in that channel
- If one instance → route directly, no prompt
- If multiple → show
external_selectdropdown (lazy-loads from Humr API) - Selected instance is stored as a
thread_ts → instance_idmapping - All subsequent messages in the thread route to the same instance
Each bot reply includes a Slack context block showing the active instance:
🔗 my-agent-1 · claude-code · running
This keeps the instance visible without cluttering the conversation.
Inherits from ADR-016: each thread is a new ACP session. Thread history is injected as context on each message (Slack is the source of truth for conversation history, the bot remains stateless).
HTTP Request URL for interactions. Rejected: requires a public endpoint or tunnel. Socket Mode keeps everything behind VPN.
Multiple Slack apps (one per instance). Rejected: each app needs creation + OAuth install. Doesn't scale, workspace admins won't approve dozens of apps.
Channel membership as sole access control. Considered but insufficient: teams want read-only observers in the channel while restricting who can actually trigger agent work. Two-tier model (channel + per-instance allowed users) covers both cases.
DM-based interaction with modal selector. Rejected: DM threading gets messy with multiple instances. Channels provide natural scoping and team visibility.
Persistent ACP sessions per thread. Rejected per ADR-016: Slack holds the conversation history. Fetching from Slack API on each message keeps the bot stateless.
- Single Slack app per Humr installation — simple admin
- Socket Mode means no public endpoints, but also means max 10 concurrent WebSocket connections per app (Slack limit) — sufficient for current scale
- Identity linking is a one-time step per user; must handle token refresh/expiry
- Two-tier access: channel membership for visibility, per-instance allowed users for interaction rights
- Instance-to-channel binding must be stored somewhere (instance ConfigMap spec or DB)
external_selectrequires an Options Load URL — with Socket Mode, this is handled over the same WebSocket