ADR-0018: Slack outbound agent posting — mechanism, channel allowlist, and guardrails
- Status: Accepted · Date: 2026-05-31 · Approved by: Kevin Wong (Principal)
- Authors: Engineering Director, AI Systems Director
- Amends: ADR-0015 (removes “Posting from the intranet to Slack” from the explicit non-goal list with bounded scope — see Amendment section below)
- Related: ADR-0014 (Neon + Drizzle data layer), ADR-0015 (Slack live chatter feed), ADR-0017 (Slack retention policy)
Context
ADR-0015 shipped the Slack ingest pipeline (Events API → Neon → /api/chatter) and
explicitly listed outbound posting as a non-goal. That deferral was intentional: adding
outbound capability to an ingest-only system without a defined scope, channel allowlist,
and guardrails is unsafe. The explicit blocking mechanism was Norm 11 of the Slack
strategy (docs/slack-strategy.md):
“Outbound agent posting requires a dedicated ADR first. The current bot configuration is ingest-only (ADR-0015 explicit non-goal). Agent posting to any Slack channel is blocked until the outbound posting ADR is scoped and approved.”
The Principal has approved Phase 2 of the Slack strategy. The desired state is an intranet whose chatter feed reads like a live office floor — agents narrating their work, surfacing signals, and coordinating in natural human voice. That requires agents to post to Slack from within their Claude Code sessions, not just receive messages.
This ADR scopes and enables outbound posting, removes the Norm-11 block, and defines the mechanism, channel allowlist, rate norms, and guardrails under which posting operates.
Options considered
Option A — Each agent constructs its own curl/fetch call against the Slack API
directly.
Rejected. No centralized validation — agents could post to unauthorized channels, log
tokens by accident, or embed credentials in message text. Diverges immediately across 44
agents.
Option B — A new Vercel Function /api/slack/post that agents call via HTTP (curl
from the Bash tool).
Viable but adds a server-side API surface, requires authentication of the caller (who
is the agent in a serverless context?), and introduces network round-trips through the
intranet layer for an operation that is inherently operator-session-scoped. The function
would need to enforce the allowlist server-side, but the token is already available in
the intranet environment.
Option C — A shared utility intranet/src/lib/slack.ts that agents invoke by
referencing it from a one-line Node script or MCP tool call.
Chosen. The utility encapsulates the allowlist, credential check, and Slack API call.
Any agent can invoke it with a single node -e "..." Bash tool call. No new server
endpoint required; the SLACK_OAUTH_TOKEN is already present in the Vercel environment
(set by Process Engineer per ADR-0015). The utility is auditable in one file and
consistent across all 44 agents.
Decision
1. Which agents may post
All 44 agents are permitted to post via the designated mechanism. Posting rights are tiered by channel audience:
| Tier | Agents | Channels |
|---|---|---|
| All agents | Any of the 44 KLMS agents | #all-klms (validation period only) |
| C-suite + unit leads | COO, CPTO, CGEO, PA, and all unit leads / directors | #governance, #decisions |
| PA only | Personal Assistant | #briefings |
| Engineering cluster | Engineering Director, AI Systems Director, Data & Analytics Director | #deploys |
| SRE / COO cluster | Site Reliability Engineer, COO (and cluster) | #incidents |
Individual contributors post only to channels designated for their unit or cluster.
No agent may post to channels not in the allowlist in intranet/src/lib/slack.ts,
regardless of tier.
2. Channel allowlist (initial scope)
The following channels are in the approved posting allowlist at ADR-0018 acceptance. Channels activate as their posting contracts are signed off — not all at once (Slack strategy Phase 2 gating rule).
| Channel | Scope | Activation gate |
|---|---|---|
#all-klms |
All agents | Validation period only — remove from allowlist after #governance bootstrap sequence completes |
#governance |
C-suite, unit leads, Golden Rule, HR, Archivist | Posting contract at docs/slack/posting-contracts/governance.md |
#decisions |
C-suite only | Posting contract at docs/slack/posting-contracts/decisions.md |
#briefings |
PA only | Posting contract at docs/slack/posting-contracts/briefings.md + PA CC mechanism gate |
#deploys |
Engineering Director, AI Systems Director, Data & Analytics Director | Posting contract at docs/slack/posting-contracts/deploys.md |
#incidents |
SRE, COO cluster | Posting contract at docs/slack/posting-contracts/incidents.md |
Future channels are added to the allowlist only after their posting contract is committed
to docs/slack/posting-contracts/<channel>.md and this ADR is amended to include them.
3. Mechanism
Agents post via the Slack Web API chat.postMessage method using SLACK_OAUTH_TOKEN
(already present as a Vercel environment variable and in intranet/.env.local).
The single posting primitive is intranet/src/lib/slack.ts, which exports:
postToSlack(channel: string, text: string): Promise<{ ok: true } | { ok: false; error: string }>
Agents invoke this from their Claude Code session using the Bash tool:
node -e "
import('/path/to/intranet/src/lib/slack.js').then(m =>
m.postToSlack('#deploys', 'Engineering Director here — migration 0003 ran clean.')
).then(r => console.log(JSON.stringify(r)));
"
Or, if an MCP tool with Node execution is available, via that tool. The mechanism is simple enough that any agent can invoke it without bespoke code — the entire call fits in a one-liner.
The allowlist is code, not config. It lives as an exported Set<string> constant in
slack.ts. Adding a channel requires a code change (a PR amending slack.ts and this
ADR) — not a YAML edit. This makes allowlist changes auditable and reviewable.
The required Slack OAuth scope addition is chat:write. This requires a Slack app OAuth
scope update and workspace reinstall — the same process used when channels:read was
added for ADR-0015. This is an operator action (Principal), not automated.
4. Message format
Agents post in natural human voice (Norm 2 of docs/slack-strategy.md). The agent
identifies itself naturally within the message body:
“Engineering Director here — migration 0003 ran clean. No schema drift detected.”
“PA — morning brief is up. Two open decisions waiting in #decisions.”
No machine prefixes ([AGENT:], P0:, etc.). No structured templates for general posts.
Structured formats apply only to specific channels where posting contracts define them
(e.g. #incidents requires every post to advance resolution; #briefings follows a fixed
schema — see Norm 9 and Norm 10 of docs/slack-strategy.md).
Automated webhook integrations (Vercel, GitHub Actions) are the only exception and
are not routed through postToSlack — they use native Vercel/GitHub Slack integrations
and are not surfaced as agent posts on the intranet chatter feed.
5. Rate limiting and guardrails
Rate limiting is enforced as a discipline norm at the calling-agent layer, not as a
serverless invariant. Vercel Functions are stateless across cold starts; an in-process
counter in slack.ts is warm-instance best-effort only.
The guardrails that postToSlack enforces in code:
| Guardrail | Enforcement |
|---|---|
| Channel allowlist | postToSlack validates the channel argument against the exported POSTING_ALLOWLIST Set before calling the Slack API; returns { ok: false, error: 'channel-not-allowed' } immediately if not in the set |
| Credential scan | postToSlack runs the same credential detection patterns used in events.ts (containsCredential) against the message text before posting; returns { ok: false, error: 'credential-detected' } if a match is found |
| No-throw contract | postToSlack never throws; all errors are returned as { ok: false, error: string } |
The guardrails enforced by discipline norm (agents must self-enforce):
| Guardrail | Norm |
|---|---|
| 10 messages per session per channel maximum | ADR-0018 §5 — tracked at agent layer |
| No financial figures in any channel | docs/slack-strategy.md §What Does NOT Belong |
| No legal analysis in any channel | docs/slack-strategy.md §What Does NOT Belong |
| No credentials (belt-and-suspenders with code check above) | Norm 14 |
| No posting to channels not in approved tier | §1 above |
6. chat:write scope addition
The existing SLACK_OAUTH_TOKEN (xoxb-) does not include chat:write. The operator
(Principal) must:
- Open the KLMS Slack app configuration.
- Add
chat:writeto the OAuth scopes under Bot Token Scopes. - Reinstall the app to the workspace (Slack requires reinstall on scope changes).
- Confirm the new token value is updated in
intranet/.env.localand in the Vercel Production environment variables.
This is a manual operator action. No code changes are required for this step. The
postToSlack utility will return { ok: false, error: 'missing_scope' } (Slack’s error
string) until the scope is added — it will not crash or expose the missing scope in logs.
7. Non-goals
This ADR does not scope and the mechanism does not support:
- Threaded replies or responding to existing messages
- Adding or reading reactions
- File or attachment uploads
- Direct messages to workspace members
- Posting to private channels
- Posting to channels outside the allowlist, including any bot-excluded channels
(
#brand-approvals,#research-findings,#finance-alerts,#legal-compliance) - Any runtime mechanism for dynamically adding channels to the allowlist (code change required)
Amendment to ADR-0015
The following line in ADR-0015 §Explicit non-goals:
“Posting from the intranet to Slack”
is narrowed as follows, effective ADR-0018 acceptance:
“Posting from the intranet to Slack — narrowed by ADR-0018. Outbound posting is now in scope for the channels listed in the ADR-0018 allowlist (
intranet/src/lib/slack.tsPOSTING_ALLOWLIST), via thepostToSlackutility, subject to ADR-0018 guardrails. Posting to all other channels, including private/bot-excluded channels, remains a non-goal.”
All other non-goals in ADR-0015 are unchanged.
Consequences
Positive:
- The chatter feed becomes a live office floor as intended — agents narrate their work in natural voice rather than operating silently.
- A single, auditable posting primitive (
slack.ts) ensures all outbound posts pass the same allowlist and credential checks, regardless of which agent is posting. - The allowlist-in-code design makes channel additions require a PR, not a config edit — changes are reviewable and traceable.
postToSlacknever throws, so an agent session is not interrupted by a failed post.
Negative / trade-offs:
- The
chat:writescope requires a manual Slack app reinstall by the Principal before any outbound posting works. Until then, all calls return{ ok: false, error: 'missing_scope' }. - Per-session rate limiting (10 posts per channel) is a discipline norm, not a technical constraint. Over-posting by a misbehaving agent is possible until enforcement is strengthened.
- Per-channel posting contracts still gate activation.
#all-klmsis the only immediately usable channel for end-to-end validation; all other channels require their contracts to be committed before agents use them. - The utility runs in the intranet Node environment; agents in pure Bash sessions must
use
node -e "..."to invoke it. An MCP tool that wrapspostToSlackwould reduce friction — deferred to a follow-on task.
Follow-on decisions deferred:
- An MCP tool wrapping
postToSlackto reduce per-agent invocation friction. - Removal of
#all-klmsfrom the allowlist after the #governance bootstrap sequence completes (the comment inslack.tsmarks this as a TODO). - Per-channel posting contracts for each Phase 2 channel (owner: per allowlist table above).
- Strengthening rate limiting from discipline norm to technical enforcement once session context is available (possible via a session-scoped state store or cron tracking).