Slack integration¶
The :pa (Personal Assistant) mode includes an optional Slack integration that stores incoming messages in a durable inbox and lets the AI triage them on demand. Messages never auto-inject into the chat log β instead a discreet status-line indicator shows the unread count, and Ctrl-S opens a peek overlay for at-a-glance review.
The integration is inert until you configure it. Without slack.enabled: true and a bot token, no listener spawns, no Slack tools register, and the AI sees zero Slack surface.
What the integration does¶
- Subscribes to your Slack workspace via Socket Mode (no public webhook endpoint required)
- Filters incoming events to mentions of the bot, DMs to the bot, and replies in threads the bot has previously posted in or that you've explicitly watched
- Persists every event to a durable JSONL inbox under
~/.local/state/bnerd/slack-inbox/plus astate.jsonfor read/dismissed bookkeeping - Surfaces unread count on the
:pastatus line (π¬ N) with a brief flash on each new arrival Ctrl-Speek overlay: list, mark read, dismiss, or hand off to:pafor reply / ticket drafting- Lets the AI search the inbox (
slack_inbox_search) or Slack's own index (slack_search, requires user token), draft replies (with full preview before send), or propose OpenProject tickets from thread excerpts - Catches up on messages that arrived while bnerd was offline (DMs and watched-thread replies always; channel mentions when a user token is configured)
- Optional first-run backfill of the last 30 days as historical context (entries marked already-read)
- Auto-watches threads the bot replies in so follow-ups land in the inbox
What it deliberately does NOT do¶
- Auto-reply without a preview (every send goes through the
slack_propose_replySend/Refine/Cancel dialog plus the standard write-confirm) - Push messages into the AI's chat log as they arrive β the inbox is the new home for that data; the AI gets a small awareness note in its system prompt
- Auto-classify every arriving message with an LLM (cost would scale with workspace traffic β by design, classification is on-demand, triggered by your queries)
- Mirror the Slack app's per-device read state (Slack's API doesn't expose it; the tool tracks its own read state)
- Impersonate you (replies are posted by the bot user; recipients see the bot identity plus the configured signature suffix, not your account)
- Microsoft Teams (Slack only for now β see the roadmap)
Setup¶
1. Create a Slack app¶
In your workspace's Slack admin, create a new app from a manifest. The minimum required scopes:
Bot Token Scopes (xoxb-β¦):
app_mentions:readβ to receive @mention eventschannels:historyβ to read public-channel messagesgroups:historyβ to read private-channel messages (if needed)im:historyβ to read DMs to the botim:readβ to enumerate DM conversations (used by catchup)chat:writeβ to post repliesusers:readβ to resolve user IDs to display names in previews
Event Subscriptions (Socket Mode β no Request URL):
app_mentionβ bot was @mentionedmessage.channelsβ public-channel messages (filtered client-side)message.groupsβ private-channel messagesmessage.imβ DMs to the bot
Socket Mode: enable it. Generate an app-level token (xapp-β¦) with the connections:write scope. This is what lets the CLI maintain a persistent websocket without needing a public HTTPS endpoint.
Optional User Token (xoxp-β¦): adds two capabilities on top of the bot-only setup.
- Search (
slack_searchand offline mention catchup) β the bot token cannot callsearch.messages; Slack scopes it to user tokens only. - Personal-inbox poller + subscriptions β with a user token, bnerd can read every conversation you can see (DMs, group DMs, private channels, public channels), not just the bot's slice. Each conversation is a subscription with one of three surface modes:
muted(skip),mentions(surface only @-mentions of you),all(every non-bot message). A background poller (30sby default, configurable) fans the user-scope events into the same inbox the bot listener writes to.
To enable both, add these scopes to the User Token Scopes during install:
search:readβ slack_search + mention catchupim:read,im:historyβ DM enumeration + history (user-scope poller)mpim:read,mpim:historyβ group DMsgroups:read,groups:historyβ private channelschannels:read,channels:historyβ public channels you've joinedusers:readβ display-name resolution for user-scope events
Install (or reinstall) the app, capture the resulting xoxp-β¦ token.
Migrating from a search-only user token¶
If you already have a user token with just search:read (the Phase 5 surface), you can upgrade in-place:
- In the Slack app's OAuth & Permissions page, add the scopes above to User Token Scopes.
- Click Re-install to Workspace so Slack issues a new token with the broader scopes. The token string changes; capture it.
- Replace
slack.user-tokenin~/.bnerd.yaml(orSLACK_USER_TOKEN/BNERD_SLACK_USER_TOKENin your env). - Run
bnerd slack discoveronce to enumerate your conversations and seed the subscription registry with type-based defaults (DMs βall, group DMs βall, private channels βmentions, public channels βmuted).
Install the app to your workspace. Capture the bot token (xoxb-β¦), the app-level token (xapp-β¦), and optionally the user token (xoxp-β¦).
2. Configure bnerd¶
Add the slack block to ~/.bnerd.yaml:
slack:
enabled: true
bot-token: xoxb-1234567890-β¦
app-token: xapp-1234567890-β¦
user-token: xoxp-1234567890-β¦ # optional; enables slack_search, mention catchup, and the user-scope poller / subscriptions
signature-suffix: "β via bnerd.ai" # optional; "-" to disable
backfill-days: 30 # optional; first-run + per-subscription history pull. 0 β 30, negative skips
retention-days: 90 # optional; rolling window before files become "archived"
catchup-on-start: true # optional; default true. Set false on slow networks
# User-scope poller (active only when user-token is set)
user-poll-enabled: true # optional; default true iff user-token is configured. Set false to disable
poll-interval: 30s # optional; how often the user-scope poller ticks. Floor 5s.
subscriptions-path: "" # optional; override the on-disk registry path. Empty = XDG default
Or via environment variables (priority: CLI flag β BNERD_SLACK_* β SLACK_* β YAML):
export SLACK_BOT_TOKEN=xoxb-β¦
export SLACK_APP_TOKEN=xapp-β¦
export SLACK_USER_TOKEN=xoxp-β¦ # optional
signature-suffix is appended idempotently to every outbound message, mirroring the OpenProject via bnerd.ai tagging convention. Set it to - (literal dash) to disable.
3. Verify credentials¶
Before launching :pa, smoke-test the bot token:
You should see the workspace name, bot user ID, and signature suffix. If you get invalid_auth, check that the bot token starts with xoxb- and that the app is installed to your workspace.
To verify event delivery without touching the TUI:
This connects via Socket Mode and prints any surface-worthy events to stdout. From another Slack account, @mention the bot or DM it β you should see lines like:
[mention] #C0123456 @U7654321 ts=1700000123.000100 thread=
hey <@U99> can you check the staging deploy?
Press Ctrl+C to stop.
4. (Optional) Run an initial backfill¶
To pull the last 30 days of context into the durable inbox so search and AI triage have history to work with:
Backfilled entries are marked already-read, so they don't pollute the unread indicator. The command is idempotent β once state.json records first_run_completed: true, subsequent runs are no-ops. Use bnerd slack catchup --since 30d to force a re-pull.
5. Open :pa¶
You'll see a system block on entry confirming the listener connected:
If catchup-on-start is enabled (default), bnerd also pulls any messages that arrived while you were offline before the live listener takes over.
Using the integration¶
The status indicator¶
When unread Slack messages exist, a chip appears in the top header:
It pulses brighter for ~1.2s on each new arrival. When the unread count reaches zero, the chip disappears.
Peeking with Ctrl-S¶
Press Ctrl-S from any view (zones, k8s, :pa, etc.) to pop the inbox peek overlay:
ββ π¬ Slack Inbox (3 unread, 5 shown) βββββββββββββββββββββββ
β β #ops @dimitri 14:23 staging deploy is broken β
β β DM @alice 13:50 can you take a look? β
β β #infra @carol 11:02 bumping limits today β
β #ops @bob 10:50 merged the PR β
β #ops @bob 10:30 pulling latest β
ββ ββ/jk navigate β Enter read β d dismiss β r reply β t ticket β Esc close β
Keybindings:
| Key | Action |
|---|---|
β/β or k/j | Move cursor |
g / G | Jump to first / last row |
Enter | Mark the focused row as read (drops it from the indicator) |
d | Dismiss β explicit "noise, never count this again" |
r | Switch to :pa with a pre-filled prompt: "draft a reply to β¦" |
t | Switch to :pa with a pre-filled prompt: "propose a ticket β¦" |
Esc / Ctrl-S / q | Close the overlay (returns to whatever view you were in) |
The overlay's row order is unread first, then read (newest first within each group). Dismissed entries are hidden from the default list but stay in the JSONL log for slack_inbox_search.
Asking the AI¶
Inside :pa, the AI is told via the system prompt's dynamic context how many unread messages there are and the top senders, so it can volunteer triage proactively:
You: anything I should look at?
PA: You have 3 unread Slack messages β Dimitri flagged a staging-deploy issue in #ops, Alice DM'd asking for a look at the staging cluster, and Carol mentioned bumping limits in #infra. Want me to triage them in priority order?
Or pull specific things:
- "anything new from Dimitri?" β AI calls
slack_inbox_search user=Udimitri - "messages in #ops yesterday" β
slack_inbox_search channel=Cops since=β¦ - "what did Alice say last month about staging?" β
slack_search(requires user token) - "triage my inbox" β AI walks the unread set, proposes replies and tickets per item
Drafting a reply¶
The AI calls slack_propose_reply with a full draft. The TUI shows a preview block:
[propose-reply] #support Β· thread
Channel: C0123456
Thread: 1700000123.000100
In reply to: @alice β "the staging deploy is broken since 2pm"
βββββββββββββββββββββββββββββββββ
Looking now β going to check the helm rollout history first. Will
post an update in 10.
βββββββββββββββββββββββββββββββββ
β― 1. Send it
fire slack_send_reply next (still confirms once as integrity check)
2. Refine
type notes β the AI redrafts and asks again
3. Cancel
abandon the draft
After sending, the AI will mark the source inbox entry as read so the indicator updates.
Creating a ticket from a thread¶
The AI calls slack_get_thread for context, then pa_propose_ticket with the thread excerpt as the description body. Review the full draft and approve / refine / cancel as usual; on approve, op_create_work_package fires the actual create. The source inbox entry is marked read.
Watching extra threads¶
Bot replies auto-watch their thread. To watch a thread the bot hasn't posted in (e.g. you want to track a customer escalation in #support), tell the AI:
watch the thread at channel C0123 ts 1700000123.000100
It calls slack_watch_thread and the entry is persisted to the inbox state file. Stop watching with unwatch β¦ (calls slack_unwatch_thread).
Subscriptions: tracking your personal Slack¶
When a user token is configured, the integration runs a user-scope poller alongside the bot's Socket Mode listener. Where the bot only sees what it's been invited to, the poller reads every conversation you can see and surfaces it according to per-conversation subscriptions.
Surface modes¶
Each conversation has one of three modes:
| Mode | Behavior |
|---|---|
muted | Skip entirely. The poller doesn't fetch its history; new messages never enter the inbox. |
mentions | Surface only messages that @-mention you. Use for noisy public channels. |
all | Surface every non-bot message. Default for DMs and group DMs. |
Type-based defaults¶
When a conversation is first discovered, it gets a default mode from its type:
| Conversation type | Default mode |
|---|---|
Direct message (im) | all |
Group DM (mpim) | all |
| Private channel | mentions |
| Public channel | muted |
Overrides survive re-discovery β once you mute or unmute a conversation, your choice sticks.
Discovering subscriptions¶
Run once after first configuring the user token:
This enumerates every conversation the user token can see and writes them to ~/.local/state/bnerd/slack-inbox/subscriptions.json with the type-based defaults. The TUI also re-runs discovery automatically every 6 hours so newly-joined channels and freshly-opened DMs appear without a manual prompt.
Managing subscriptions from the CLI¶
bnerd slack subs list # show registry as a table
bnerd slack subs set D7XYZ all # surface every message in this DM
bnerd slack subs set C0PUBLIC muted # ignore this public channel
Setting a subscription out of muted for the first time triggers a synchronous backfill (slack.backfill-days window) so the inbox catches up on recent activity. Backfilled entries land marked read.
Managing subscriptions from :pa (Ctrl-S)¶
Press Ctrl-S to open the inbox peek, then Tab to switch to the Subscriptions pane:
ββ π Subscriptions (12 shown) Β· Tab β Messages βββββββββββββββββββββ
β ββ Direct Messages ββ β
β β alice (3 new) 14:23 β
β β bob β
β ββ Private Channels ββ β
β β ops (1 new) 13:50 β
β β platform-eng β
β ββ Public Channels ββ β
β β general β
β β random β
ββ ββ/jk navigate β Space select β m/n/a mute/mentions/all β b backfill β x clear sel β / filter β Tab messages β Esc close β
| Glyph | Mode |
|---|---|
β (accent) | all |
β (primary) | mentions |
β (muted) | muted |
Keys (subscriptions pane):
| Key | Action |
|---|---|
ββ / jk | Navigate |
Space | Toggle multi-select on the focused row |
m | Set selection (or focus) to muted |
n | Set to mentions |
a | Set to all |
b | Synchronous backfill of selection (or focus) using slack.backfill-days |
x | Clear multi-select |
/ | Enter filter mode (case-insensitive substring on name / id / type); printable chars extend, backspace deletes |
Tab | Swap back to messages |
Esc / Ctrl-S / q | Close overlay |
Multi-select scopes m/n/a/b to all marked rows; without marks, they apply to the focused row only.
Force-refresh on Ctrl-S¶
Opening the peek also kicks an out-of-band poll cycle so you never look at stale state. The header shows · refreshing⦠while it's in flight (debounced at 2s to keep rapid presses off Slack's rate limits).
Manual catchup¶
If you want to fill the inbox manually (e.g. after a long offline period or when first enabling the integration):
bnerd slack catchup # since the inbox's last-seen TS
bnerd slack catchup --since 7d # last 7 days
bnerd slack catchup --since 2026-04-01T00:00:00Z # absolute RFC3339
Catchup is idempotent β entries already in the JSONL are skipped. Inserted entries default to unread.
Storage layout¶
~/.local/state/bnerd/slack-inbox/
βββ 2026-05.jsonl # append-only events for May 2026
βββ 2026-04.jsonl # archived month (still readable on demand)
βββ state.json # read/dismissed flags, last-seen TS, watched threads
βββ subscriptions.json # per-conversation surface mode + cursor (user-token only)
Files older than slack.retention-days (default 90) are silently pruned at session start. The JSONL is human-readable β cat 2026-05.jsonl | jq works. subscriptions.json is also hand-editable; the registry survives manual edits as long as the file stays valid JSON.
Troubleshooting¶
"Slack is not configured" errors¶
Check that all of:
slack.enabled: trueis set in~/.bnerd.yaml(orBNERD_SLACK_ENABLED=trueif you split config)slack.bot-tokenresolves to a non-empty value (bnerd slack pingwill tell you which setting is missing)
Listener fails with app token required¶
Socket Mode requires both tokens. Generate the app-level token (xapp-β¦) in your Slack app's "Basic Information β App-Level Tokens", with the connections:write scope.
slack_search requires a user token¶
The Slack-side search index (search.messages) is only accessible with a user token (xoxp-β¦). Add search:read to the User Token Scopes, reinstall the app, and set slack.user-token in your config. Local-only search via slack_inbox_search works without it.
No events appear but bnerd slack ping works¶
The bot token is valid but Socket Mode isn't connecting. Check:
- Socket Mode is enabled in the Slack app config
- The app-level token has the
connections:writescope - Event Subscriptions has
message.channels,message.im, andapp_mentionenabled - The bot has been invited to the channels you want monitored (or it's a DM)
Replies appear from the bot, not from me¶
That's by design β see Non-goals. User-token impersonation requires the bot to send messages with as_user=true and the user token, which Slack restricts on new apps.
"Slack triage offline" in :pa¶
The listener crashed or disconnected. The TUI will surface the error message. Common causes: invalidated app token, network blip (Socket Mode auto-reconnects, but persistent failures are surfaced), or scope mismatch after a Slack app re-install. Restart bnerd pa after fixing.
Mention catchup is missing messages¶
Without a user token, the bot/app credentials cannot enumerate channel mentions you missed while offline (Slack scopes search.messages to user tokens). Add slack.user-token to enable, or run bnerd slack catchup after the bot is re-invited to relevant channels.
See also¶
- PA rituals β how the AI orchestrates standups, replans, and the slack-triage workflow alongside OpenProject and mission control
- MCP tools reference β exact tool surface and JSON schemas