Skip to content

Slack

What it gives you β€” findings delivered to a channel, with optional Approve/Reject buttons, one-click πŸ‘/πŸ‘Ž diagnosis feedback, and a πŸ”• control to silence a known incident.

Minimal config

Incoming webhook (simplest β€” single message per finding):

notify:
  slack:
    webhook_url_env: SLACK_WEBHOOK_URL

Or a bot token (chat.postMessage) β€” posts a verdict-first summary then the full analysis as a threaded reply; the bot must be a member of the channel (invite it / conversations.join):

notify:
  slack:
    bot_token_env: SLACK_BOT_TOKEN         # xoxb-… (takes precedence over webhook_url_env)
    channel: C0123456789                   # channel ID or name to post to

Verify it locally

kubectl -n runlore create secret generic runlore-secrets \
  --from-literal=SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'

Fire a test incident (see Alertmanager or hack/demo.sh) and confirm delivery:

kubectl -n runlore logs deploy/runlore | grep 'msg=findings'

Notes

  • A bot token takes precedence over webhook_url_env when both are set.
  • An incoming webhook delivers the same content as a single message β€” it cannot thread. It does carry interaction buttons: incoming webhooks follow the same messaging rules as chat.postMessage, and a click is answered through the interaction’s response_url, so neither Approve/Reject nor feedback_buttons needs a bot token.
  • Approve/Reject buttons (actions.mode: approve), feedback_buttons (opt-in πŸ‘/πŸ‘Ž rating) and silence_button (opt-in πŸ”• control β€” see Silence a recurring incident below) all need signing_secret_env set and the same exposure: Slack Interactivity turned on, with Request URL https://<your-runlore-host>/slack/interactions reachable from Slack’s servers.
    • api.slack.com/apps β†’ your app β†’ Interactivity & Shortcuts β†’ toggle On, Request URL = https://<your-runlore-host>/slack/interactions.
    • Read-only deployments (no actions, no feedback buttons, no silence button) need none of this.
    • Route it through your ingress/gateway; if you use the chart’s networkPolicy.ingressFrom, allow your ingress controller, not the internet.
  • feedback_buttons: true also requires outcome.ledger_path and a delivery target (webhook_url_env, or bot_token_env with channel) β€” startup fails loud otherwise, since a button only exists on a message RunLore actually delivered. Ratings land in the outcome ledger and weigh the recalled entry’s trust, exactly like resolve signals do. silence_button has the identical requirement, for the identical reason β€” its control lives on the same message.
  • Mount the credential too: an env var that is set but empty skips the Slack notifier altogether β€” nothing is delivered, no buttons render, no rating can be recorded. Validation cannot see that, so startup warns (no slack delivery target resolved) instead of reporting the feature enabled.
  • Feedback is deliberately unprivileged: any signature-valid member of your workspace can rate (approve/reject keep their own approver_ids allowlist). One live vote per (trigger key, Slack user); latest wins.
  • signing_secret_env HMAC-verifies every button click (Β±5 min replay window).

Silence a recurring incident

notify.slack.silence_button (opt-in, default false) adds a third control to investigation messages, beside πŸ‘/πŸ‘Ž: a labelled πŸ”• Silence… dropdown offering the durations listed in notify.silence.windows (e.g. 1h, 4h, 24h). List between 2 and 5 of them β€” startup rejects anything outside that range while this flag is on. Both bounds are a UX judgement rather than a Slack limit: the menu is a quick choice on a card someone is reading at 3am, so a single option is a button wearing a dropdown and six is a form. A Matrix-only deployment renders no menu and is free of them.

Where a πŸ‘/πŸ‘Ž records an opinion about the diagnosis, picking a window from the πŸ”• menu changes what RunLore does: it suppresses re-investigating this exact TriggerKey for the chosen window β€” no model call, no notification, no ledger open β€” enforced in RecurrenceGate.decide before the paid investigation loop even starts. It works even with investigation.recurrence_cooldown left at its default of 0 (off): the human silence check and the machine cooldown are independent, and the silence is checked first.

A click rewrites the card in place. The πŸ”• control is removed β€” it has done its job, and a second click on a card that already says it is silenced is only confusing β€” and a line naming who silenced the finding and until when is appended, visible to the whole channel. The name renders as a real Slack mention. πŸ‘/πŸ‘Ž stay on the card, because a πŸ‘Ž is how a colleague lifts the silence early. Scrollback therefore distinguishes a handled finding from an unhandled one.

That public line is in addition to the private acknowledgement described below, not instead of it. The two answer different readers: the marker tells a colleague scanning the channel a day later that the finding is already handled, while the acknowledgement tells whoever clicked what they just switched off β€” and which escapes this deployment actually has.

The card is left untouched if Slack sends no blocks back with the interaction, if Slack refuses the rewrite, or if the card had already been rewritten (a second click on a stale card β€” the marker is never stacked twice). The silence is recorded in every one of those cases; a marker is never worth risking the finding it marks. When the card does go unmarked the acknowledgement says so, because otherwise the person who clicked would reasonably assume the channel had been told β€” and they are the only one who can now say it.

The silence stands until one of four things happens:

  • the window expires;
  • the incident fires again as CRITICAL β€” a silence never mutes a page, the same carve-out the debouncer makes for criticals;
  • a colleague casts a standing πŸ‘Ž on the trigger β€” the newest human wins, so a πŸ‘Ž cast after the silence re-arms investigation immediately (and a πŸ”• clicked after the newest standing πŸ‘Ž still suppresses). This escape only exists where a πŸ‘/πŸ‘Ž control is actually enabled β€” feedback_buttons here, or matrix.feedback_reactions β€” since a πŸ‘Ž nobody can cast bounds nothing. Votes and silences share one outcome ledger, so a πŸ‘Ž on either transport counts;
  • the incident resolves β€” a resolve clears the silence outright, so the next occurrence gets a fresh look.

Two of those four are alert-specific and do not apply to a GitOps-sourced failure. FromFailureEvent never sets a severity, so the CRITICAL carve-out can never fire; and a GitOps failure’s fingerprint is synthetic, so it has no resolve channel at all β€” nothing can ever clear the silence early. For a GitOps failure, a silence is bounded by its expiry and a colleague’s πŸ‘Ž β€” and nothing else. The same loss of the resolve escape (but not the CRITICAL one, since a real severity is still present) applies to an Alertmanager receiver configured with send_resolved: false.

And a third is config-specific. With no πŸ‘/πŸ‘Ž control enabled on any transport β€” the feedback_buttons: false + silence_button: true deployment described further down β€” there is no way to cast a πŸ‘Ž, so that escape does not exist either. Combine that with a GitOps trigger and the expiry is the only bound left: a mistaken 24h click cannot be lifted by anyone. RunLore warns at startup when silencing is enabled with no πŸ‘/πŸ‘Ž control anywhere, and the acknowledgement below names only the escapes that deployment actually has. If you want the silence-only card, keep notify.silence.max_window short.

The click is acknowledged with a message naming who silenced it, until when, and restating the escapes that apply to this deployment, so nobody has to guess why the channel went quiet.

Like feedback_buttons, silencing is deliberately unprivileged: any signature-valid member of your workspace can silence an incident β€” there is no approver_ids-style allowlist. That is safe for an alert-sourced incident with a πŸ‘/πŸ‘Ž control enabled, where the blast radius is bounded four independent ways (the escapes above); for a GitOps failure, a send_resolved: false receiver, or a deployment with no πŸ‘/πŸ‘Ž control, it is narrower β€” per above β€” and in the worst combination the expiry is the only bound, which is why max_window matters most there. Every silence is attributed to the clicking Slack user’s id in the outcome ledger (so a bad click is auditable, not anonymous), and the window itself is capped by notify.silence.max_window β€” nothing can be silenced indefinitely.

notify:
  silence:
    windows: [1h, 4h, 24h]
    max_window: 24h
  slack:
    webhook_url_env: SLACK_WEBHOOK_URL   # or bot_token_env + channel β€” either delivery target works
    silence_button: true
    signing_secret_env: SLACK_SIGNING_SECRET
outcome:
  ledger_path: /data/outcome.jsonl

silence_button is gated independently of feedback_buttons β€” a deployment can enable either without the other, and each renders only its own control on the card. With feedback_buttons: false and silence_button: true, an investigation message shows the πŸ”• menu and nothing else β€” which also means no πŸ‘Ž can be cast, so that escape from a silence is gone. Enable matrix.feedback_reactions (votes and silences share one ledger, so a πŸ‘Ž there still counts) or accept the narrower bound; startup warns either way.

The button is not the only way in. With thread_capture also on (see below), replying @runlore silence: 4h in the investigation thread does exactly what the button does β€” same parser, same ledger write, same acknowledgement β€” because Slack’s thread mentions and the πŸ”• button both route through the one shared thread.Responder. silence: must start the message (after the mention); mentioned mid-sentence it is answered with a request to rephrase, never acted on, since acting writes a ledger event and mutes the incident. That command path is gated on notify.silence being enabled at all (either silence_button here or Matrix’s silence_reactions β€” whichever transport you turned it on for) plus Slack’s own thread_capture, not specifically on silence_button β€” so a deployment running thread_capture: true with silence_button: false still accepts silence: 4h typed in a thread, as long as silencing is enabled somewhere. See Matrix β†’ Silence a recurring incident for the same command in detail β€” the grammar and the ledger write are identical across both transports.

Write knowledge back from a thread

With thread_capture on, replying to a finding’s thread records what you know into the knowledge base:

@runlore note: the real cause was the spot-node reclaim at 14:02

The note lands as a comment on that finding’s knowledge-base PR. When the finding has no PR β€” an instant recall, or a no_action verdict β€” RunLore opens a small Concept entry PR instead, so the knowledge still lands somewhere. A human reviews and merges it, like every other entry. If nobody does, the curate stale sweep still applies: an untouched note PR past curate.stale_after (the Helm chart ships 720h) is closed like any other stale draft, with a comment explaining why β€” it can be reopened for review at any time.

@runlore reinvestigate: … is reserved and not supported yet; add the reinvestigate label to the knowledge-base issue to re-run an investigation.

notify:
  slack:
    bot_token_env: SLACK_BOT_TOKEN
    channel: C0123456789
    signing_secret_env: SLACK_SIGNING_SECRET
    thread_capture: true
outcome:
  ledger_path: /var/lib/runlore/outcome.jsonl   # required: the thread registry lives beside it

thread_capture: true also requires outcome.ledger_path β€” the thread registry that maps a Slack thread to its investigation is stored beside the ledger. Startup fails loud without it, the same way it fails loud without signing_secret_env, bot_token_env and channel above.

That location surviving a restart or a leader failover depends on your deployment shape, not on outcome.ledger_path alone: the Helm chart’s default (persistence.enabled: false) is an emptyDir, destroyed on every restart, upgrade or failover, and a StatefulSet + ReadWriteOnce volume still gives each replica its own copy β€” only persistence.enabled: true with workloadKind: Deployment and a ReadWriteMany accessMode puts the registry on the one volume every replica shares. See Configuration β†’ notify for the full breakdown. Without that combination, a restart or failover empties the registry: a reply to a thread delivered before it gets “I don’t have context for this thread,” even though the finding is still on screen.

Pre-flight: prove the endpoint answers

POST /slack/events 404s until thread_capture: true is actually deployed and running, so configure the Event Subscription only once that rollout is live β€” otherwise Slack’s URL verification fails and the feature looks broken before it’s even been tried. Send one unsigned POST from outside the cluster, against the hostname Slack will use, and read the body, not just the code β€” the body is what tells you whether RunLore answered or something in front of it did:

$ curl -sS -w '\n%{http_code}\n' -X POST https://<your-runlore>/slack/events
unauthorized
401
codebodywhat it meansdo next
401unauthorizedthe request reached RunLore and failed signature verification: the path routes and the HMAC is enforcedconfigure Slack
404slack events not enabledRunLore answered β€” the path routes, but the handler is not wiredsee not wired below
404anything else (HTML, default backend)your ingress answered β€” the path is not routed to RunLoresee not routed below
200anythingnothing is verifying signatures on that URLstop β€” do not point Slack at it
405Method Not Allowedyou sent a GET; the route is POST-onlyresend with -X POST
503no leader known; retry / leader unreachable; retryleader-forwarded, and no leader is routable right nowretry once leader election settles
421not the leader (request already forwarded once)mid-failover, on a stale holder viewretry against the Service
anything elseβ€”the ingress or a proxy answered, not RunLore (502, 504, a redirect to an SSO page…)read the body; fix routing first

Not wired. A handler that is actually serving announces itself at startup:

msg="slack thread capture enabled" endpoint=/slack/events

No such line β‡’ grep the warning beside it, which names the reason: no forge is configured, no bot-token delivery target resolved, or no thread-capable notifier resolved. The endpoint is also unserved when signing_secret_env names a variable that is present but empty (an unmounted secret, a blank Helm value) β€” an endpoint that cannot verify a signature is not exposed at all. That one is the trap: the startup line still prints, and nothing warns. So treat the pre-flight as authoritative for routing, and the startup log as authoritative for wiring; check both.

Not routed. /slack/events is a separate route from /slack/interactions. On an ingress that lists paths individually rather than forwarding the whole service, that is the failure this check exists to catch: Approve/Reject and the feedback buttons keep working, so the Slack integration looks healthy, while every mention silently never arrives. Route both.

Configure the Slack app

Warning

Reinstalling can issue a new bot token β€” verify, don’t assume. If it does and your secret store still holds the previous value, finding delivery stops outright, which is loud. The quiet half is threads posted before the rotation: a note against one still reaches the knowledge base and only the acknowledgement fails, which reads as broken capture. Verify against the value the cluster actually holds, not whatever is in your shell:

$ TOKEN=$(kubectl -n runlore get secret runlore-secrets \
    -o jsonpath='{.data.SLACK_BOT_TOKEN}' | base64 -d)
$ curl -sS -X POST -H "Authorization: Bearer $TOKEN" https://slack.com/api/auth.test
{"ok":true,…}

{"ok":false,"error":"not_authed"} means the token was empty, not that it was rejected. invalid_auth, token_revoked, token_expired and account_inactive all mean the same remedy: copy the current Bot User OAuth Token from OAuth & Permissions into the secret store, then restart the pod β€” the token is read from the environment once, at startup, so until you restart, the pod is still using whatever the Secret held when it started.

The signing secret is per-app, not per-install, so a reinstall never touches it: mentions keep arriving, and /slack/events keeps answering 401 to the pre-flight, while replies fail.

  1. OAuth & Permissions β†’ add the app_mentions:read bot scope (chat:write is already required for delivery), then reinstall the app.
  2. Event Subscriptions β†’ enable, set the Request URL to https://<your-runlore>/slack/events, and subscribe to the bot event app_mention. Slack verifies the URL with a signed challenge, so the endpoint must be reachable before you save.

The bot must be a member of the channel: Slack only sends app_mention from conversations the app is in. Delivery already requires this, so if findings are arriving, it is satisfied.

Saving the Request URL is the live test of signing_secret_env: Slack signs the url_verification challenge like every other delivery, and RunLore verifies it before echoing it back. Success is silence β€” an accepted challenge logs nothing. A mismatched secret logs msg="rejected slack event: bad signature" and Slack reports the URL as unverified.

Only app_mention is subscribed β€” RunLore reads nothing in channels where it was not directly addressed.

Announce a knowledge write

A captured note is reported into the thread it came from, and nowhere else β€” so the knowledge lands, but nobody outside that thread learns it did. announce_kb_updates changes that: every write that reaches the forge is also announced, with its own message naming who wrote the note and where.

notify:
  slack:
    bot_token_env: SLACK_BOT_TOKEN
    channel: C0123456789
    signing_secret_env: SLACK_SIGNING_SECRET
    thread_capture: true
  thread:
    announce_kb_updates: channel  # default false; also true (= channel), thread, both
outcome:
  ledger_path: /var/lib/runlore/outcome.jsonl   # required by thread_capture, as above

The post names the pull request, the entry that was created, who the note came from and which chat system they typed it in, and quotes the note itself β€” capped at 512 bytes, with the pull request holding the full text. It carries the same secret-redacted, capped text that reached the forge, and everything a human or the model wrote in it is escaped before posting: a <!channel> inside a note renders as literal text, never as a channel-wide ping. That holds wherever it lands.

Where it lands is yours to pick. channel (what true has always meant) posts to the channel this notifier delivers findings to. thread posts into the thread the note was typed in. both does each.

  • channel produces two messages for one write, and with several sinks that is the point. The thread reply answers the person who typed; the channel post reaches everyone who was not reading that thread. But with Slack as your only transport those are the same people β€” the thread lives in that very channel β€” so each write restates in the channel what the thread just said. That is what thread is for: the announcement goes into the thread instead, keeping the provenance line the reply does not carry, without the echo.
  • thread never silences your other sinks. A Matrix room, an incoming-webhook Slack, a webhook endpoint β€” none of them can post into a Slack thread, so they each receive the announcement in their own channel exactly as before. The echo only exists where the thread and the channel are the same place.
  • The announcement carries note content. A note written in a thread nobody else was watching is broadcast to every sink you have configured β€” this channel, a Matrix room if you run both, any webhook endpoint. That is your decision to take knowingly, which is why the key is off unless you set it.

Conversational replies

Without model.chat, anything you say in the thread that is not note: gets a fixed reply telling you so, and nothing is recorded. Adding a model.chat block changes that: RunLore answers the question instead, from the investigation’s own evidence plus the top knowledge-base hits, and files a note itself when your message contained a durable fact.

model:
  chat:
    model: claude-haiku-4-5      # required β€” never inherited from model.model
notify:
  slack:
    bot_token_env: SLACK_BOT_TOKEN
    channel: C0123456789
    signing_secret_env: SLACK_SIGNING_SECRET
    thread_capture: true         # the chat layer has no channel without it
  thread:
    chat_calls_per_hour: 30      # default 30
    chat_tokens_per_hour: 109480 # default 109480 β€” derived, not round
outcome:
  ledger_path: /var/lib/runlore/outcome.jsonl   # required by thread_capture, as above

This is a paid path anyone in the channel can trigger. With model.chat set, every message that mentions the app in an investigation thread and is not a recognised command costs exactly one model call β€” one structurally, not on average: the model is given a single forced tool and no search tool, so there is no agent loop.

@runlore note: … still costs nothing β€” it is the same deterministic write it always was β€” and a bare mention with nothing after it costs nothing either.

Spend is bounded by two global hourly ceilings (chat_calls_per_hour, chat_tokens_per_hour) shared across every thread and both transports. The token default is derived β€” what one maxed-out call can cost, times the call budget β€” which is why it is not a round number and why it moves when max_note_bytes moves. It is not bounded in currency: model.pricing is a reporting table, not a limit, and nothing compares a cost to a threshold and stops. Read Conversational replies and what they cost before enabling it β€” it states the full set of bounds and, just as plainly, what has none.

Reference