Skip to content

ADR-0015: Slack live chatter feed — Events API + Neon + client polling

Guide

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:

  1. /api/slack/events — a Vercel Function that receives Slack Events API pushes, verifies the X-Slack-Signature HMAC, handles the URL verification challenge on first install, and writes message events to Neon (chatter_messages).

  2. /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 existing chatter.json contract so chatter.astro needs minimal changes.

  3. Astro adapter change — switch intranet/astro.config.mjs to use @astrojs/vercel with output: 'hybrid'. All existing routes stay static (no behavior change); only the two /api/* routes are server-rendered. This is the minimum viable adapter configuration.

  4. Client-side polling — chatter.astro polls /api/chatter?since=<last-ts> every 30 seconds, appends new messages to the DOM, and preserves scroll position. No full-page reload.

  5. One-time backfill script — scripts/slack-backfill.mjs pulls 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_messages was 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/chatter response per open session). Negligible on Hobby plan; monitor if usage grows.
  • @astrojs/vercel adapter is a new dependency in intranet/package.json. Astro hybrid mode is stable as of Astro 4+; no known risks for this configuration.
  • SLACK_SIGNING_SECRET must 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_ts fields 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.