Skip to content

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 a state.json for read/dismissed bookkeeping
  • Surfaces unread count on the :pa status line (πŸ“¬ N) with a brief flash on each new arrival
  • Ctrl-S peek overlay: list, mark read, dismiss, or hand off to :pa for 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_reply Send/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 events
  • channels:history β€” to read public-channel messages
  • groups:history β€” to read private-channel messages (if needed)
  • im:history β€” to read DMs to the bot
  • im:read β€” to enumerate DM conversations (used by catchup)
  • chat:write β€” to post replies
  • users:read β€” to resolve user IDs to display names in previews

Event Subscriptions (Socket Mode β€” no Request URL):

  • app_mention β€” bot was @mentioned
  • message.channels β€” public-channel messages (filtered client-side)
  • message.groups β€” private-channel messages
  • message.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.

  1. Search (slack_search and offline mention catchup) β€” the bot token cannot call search.messages; Slack scopes it to user tokens only.
  2. 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 (30s by 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 catchup
  • im:read, im:history β€” DM enumeration + history (user-scope poller)
  • mpim:read, mpim:history β€” group DMs
  • groups:read, groups:history β€” private channels
  • channels:read, channels:history β€” public channels you've joined
  • users: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:

  1. In the Slack app's OAuth & Permissions page, add the scopes above to User Token Scopes.
  2. Click Re-install to Workspace so Slack issues a new token with the broader scopes. The token string changes; capture it.
  3. Replace slack.user-token in ~/.bnerd.yaml (or SLACK_USER_TOKEN / BNERD_SLACK_USER_TOKEN in your env).
  4. Run bnerd slack discover once 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:

bnerd slack ping

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:

bnerd slack listen

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:

bnerd slack backfill

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

bnerd pa

You'll see a system block on entry confirming the listener connected:

Slack triage active β€” connected as bnerd-ai in Acme Inc.

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:

☁ b'nerd  ctx:org-1234  prj:platform  πŸ“¬ 3

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:

bnerd slack discover

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: true is set in ~/.bnerd.yaml (or BNERD_SLACK_ENABLED=true if you split config)
  • slack.bot-token resolves to a non-empty value (bnerd slack ping will 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:write scope
  • Event Subscriptions has message.channels, message.im, and app_mention enabled
  • 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