ADR-0015: Slack live chatter feed — Events API + Neon + client polling
- Status: Accepted · Date: 2026-05-31 · Approved by: Kevin Wong (CPTO)
- Amends: ADR-0014 (narrows the “no server functions” clause for two bounded endpoints — see below)
- Related: ADR-0006 (intranet static portal), ADR-0008 (Vercel deploy), ADR-0009 (deploy budget), ADR-0014 (Neon + Drizzle data layer)
Context
The Principal wants the intranet chatter page to show a real-time feed of all Slack
channels, not the static seed data (src/data/chatter.json) currently in place. A
SLACK_OAUTH_TOKEN (xoxb-) already exists in intranet/.env.local.
ADR-0014 stated: “The intranet stays output: static. No adapter, no SSR, no server
functions are added.” That clause must be amended. A live Slack feed requires at minimum:
(a) a server endpoint to receive Slack push events, and (b) a read endpoint the browser
can poll. This ADR makes that amendment with a bounded scope and records the rationale.
Options considered
Option A — Client-side polling of Slack API directly.
Rejected. The SLACK_OAUTH_TOKEN would be visible in the browser bundle. A Tier-3
endpoint (conversations.history) under polling load for all channels burns rate-limit
budget continuously whether or not Slack has new messages. Token exposure is a hard no.
Option B — Slack Socket Mode (true WebSocket push). Rejected. Incompatible with serverless/Vercel. Requires a persistent process.
Option C — Build-time snapshot + client-triggered rebuild. Rejected. ADR-0009 constrains us to ~one deploy per session. “Real-time” via rebuild would be hours stale and would exhaust the Vercel daily budget (100 deployments/day).
Option D — Vercel Function server-side proxy + ~30s client polling. Partially correct. A server proxy is necessary to keep the token out of the browser. But polling Slack’s REST API from the server on every client poll interval is wasteful — the Slack data model does not change that frequently, and every server invocation makes an outbound Slack API call regardless of whether there is new data.
Option E (chosen) — Slack Events API push → Vercel Function webhook → Neon → client
polls own /api/chatter endpoint.
Slack pushes message events to a Vercel serverless function only when a message is
posted. The function verifies the Slack HMAC signature and writes to the chatter_messages
and chatter_channels tables already specified in ADR-0014 schema v1. The browser polls
/api/chatter?since=<iso> — a read against Neon, not against Slack. The Slack OAuth
token never leaves the server.
Decision
Implement the Slack live chatter feed as:
-
/api/slack/events— a Vercel Function that receives Slack Events API pushes, verifies theX-Slack-SignatureHMAC, handles the URL verification challenge on first install, and writesmessageevents to Neon (chatter_messages). -
/api/chatter— a Vercel Function that reads from Neon and returns a JSON payload of channels and messages. Supports a?since=<iso-timestamp>query param for append-only polling. The response shape matches the existingchatter.jsoncontract sochatter.astroneeds minimal changes. -
Astro adapter change — switch
intranet/astro.config.mjsto use@astrojs/vercelwithoutput: 'hybrid'. All existing routes stay static (no behavior change); only the two/api/*routes are server-rendered. This is the minimum viable adapter configuration. -
Client-side polling —
chatter.astropolls/api/chatter?since=<last-ts>every 30 seconds, appends new messages to the DOM, and preserves scroll position. No full-page reload. -
One-time backfill script —
scripts/slack-backfill.mjspulls the last N days of history from each channel the bot is a member of and populates Neon. Runs once, manually, by the operator. Not part of the build pipeline.
Amendment to ADR-0014
The clause “The intranet stays output: static. No adapter, no SSR, no server functions
are added” is narrowed as follows:
The intranet stays
output: 'hybrid'with exactly two server endpoints:/api/slack/events(webhook ingest) and/api/chatter(read API). No other server routes are added to the intranet in this slice. Any future addition of server routes requires a new ADR or explicit amendment here.
The ADR-0014 intent — avoiding SSR overhead for pages that don’t need it — is preserved.
Existing static pages (/, /docs, /people, /chatter) are unaffected.
“All channels” definition
The feed surfaces all Slack channels that the Slack bot has been invited to (i.e., channels
where the bot is a member). It does not attempt to join or enumerate all workspace channels.
The Principal must invite the bot to each desired channel. This definition avoids the
channels:join scope and prevents accidental surfacing of channels not intended for this feed.
The bot must be invited before its history is backfillable.
Required Slack OAuth scopes
At minimum: channels:history, channels:read, groups:history, groups:read,
im:history, mpim:history, users:read. Engineering Director to confirm against actual
feature scope and update the Slack app configuration before install.
Required environment variables
| Var | Exists | Purpose |
|---|---|---|
SLACK_OAUTH_TOKEN |
Yes (intranet/.env.local) |
Slack API calls (backfill, channel listing) |
SLACK_SIGNING_SECRET |
No — must add | HMAC verification on /api/slack/events |
DATABASE_URL |
Yes (Vercel + local) | Neon pooled connection for reads/writes |
SLACK_SIGNING_SECRET must be added to Vercel Production environment variables (Process
Engineer / operator action, not committed to the repo). Never hardcode or log.
Explicit non-goals for this slice
- Posting from the intranet to Slack
- Threaded replies, reactions, file/attachment handling
- Full-text search of message history
- Channel enumeration beyond bot membership
- Any other server routes on the intranet
Consequences
Positive:
- The chatter page becomes truthful — it has stated “Data from Neon” since ADR-0014 was signed; this slice makes it true.
- Slack token never reaches the browser. HMAC verification protects the webhook.
- Client polls own infrastructure (Neon) rather than Slack’s rate-limited API.
- Existing static pages are unaffected;
output: 'hybrid'adds no overhead to non-server routes. - Neon schema for
chatter_channels/chatter_messageswas already specified in ADR-0014 v1; no schema design work needed, only instantiation.
Negative / trade-offs:
- Two Vercel Function invocations per 30-second poll cycle per browser tab (one
/api/chatterresponse per open session). Negligible on Hobby plan; monitor if usage grows. @astrojs/verceladapter is a new dependency inintranet/package.json. Astro hybrid mode is stable as of Astro 4+; no known risks for this configuration.SLACK_SIGNING_SECRETmust be managed as a Vercel env var. The operator must add it before the first production deploy; the endpoint rejects all events until it is set (fail-closed design).- The backfill script must be run manually once after first deploy. The chatter page will show an empty or sparse state until backfill completes.
- Bot must be explicitly invited to each desired Slack channel. This is a Principal / operator action, not automated.
Follow-on decisions deferred:
- Retention policy for
chatter_messages(how long do we keep rows? no limit set here). - Whether to surface threading (requires
reply_count,thread_tsfields in schema). - Any outbound Slack integration (agents posting to Slack) — separate ADR if warranted.
- Vercel Function execution budget monitoring — defer to a future operations ADR if function volume grows.