Skip to main content

Runbook: Slack "Add to Slack" OAuth Install (per environment)

Scope. Stand up the Slack OAuth 2.0 install flow (issue #1159, shipped in PR #1426) for one environment, then run an end-to-end install for one org. Worked example uses dev. Implementation: api/src/channels/slack/slack-oauth.service.ts, slack-oauth.controller.ts, slack-channel-binding.service.ts. Architecture: channels.md.

This replaces the old "paste a bot token into env" flow with a standard Add to Slack authorize/callback cycle. After install, the bot token is stored encrypted per org in integration_credentials (Org ร— slack), and inbound + outbound Slack work immediately. Pulling the bot into a channel auto-binds that channel to the org's primary Specialist (no @mention needed thereafter).

Hard rule: one Slack app per environment. A Slack app's Events request_url is single-valued, so local / dev / staging / prod each need their own Slack app and their own SLACK_CLIENT_ID / SLACK_CLIENT_SECRET. Do not share one app across environments.


Part A โ€” Pre-Install (one-time, per environment)โ€‹

Do this once per environment. For dev, do it against the dev API/frontend hosts.

A1. Know your URLsโ€‹

For dev you need two public HTTPS origins:

Thingdev valueUsed for
API originhttps://api.h852.work (derived from HWORK_DOMAIN, see getPublicApiUrl())Slack Events request_url, OAuth callback
Frontend originhttps://dev.h852.work (FRONTEND_URL)post-install redirect target

Two derived URLs you'll register on the Slack app:

  • Events request URL โ†’ https://api.{HWORK_DOMAIN}/channels/slack/events (see slack.controller.ts:69)
  • OAuth redirect URL โ†’ https://api.{HWORK_DOMAIN}/channels/slack/oauth/callback (derived by getPublicApiUrl() in slack-oauth.service.ts; never the raw deployment host)

A2. Create the Slack app from the manifestโ€‹

Create the app from the manifest below โ€” it already carries the redirect URL, the full scope set, and the channel-bind events, so there is nothing to hand-fix afterwards (the in-app GET /orgs/:orgId/channels/slack/manifest generator and manifest.ts are incomplete โ€” they omit redirect_urls, the extra scopes, and member_joined_channel/member_left_channel โ€” so prefer this file).

  1. https://api.slack.com/apps โ†’ Create New App โ†’ From a manifest โ†’ pick the workspace โ†’ YAML โ†’ paste the manifest below โ†’ Create.

This is the dev manifest, ready to use as-is. For another environment, change only: display_information.name, bot_user.display_name, and the two hostnames in redirect_urls / event_subscriptions.request_url (โ†’ that env's API origin from A1).

display_information:
name: Humanwork (Dev)
description: AI specialist for your team โ€” dev/test app
background_color: "#1a1a2e"
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: HumanworkDev
always_online: true
oauth_config:
redirect_urls:
- https://api.h852.work/channels/slack/oauth/callback
scopes:
bot:
- app_mentions:read
- channels:history
- channels:read
- groups:history
- groups:read
- mpim:history
- mpim:read
- im:history
- im:read
- im:write
- chat:write
- chat:write.customize
- chat:write.public
- files:read
- files:write
- users:read
- users:read.email
- users.profile:read
- team:read
- reactions:read
- reactions:write
pkce_enabled: false
settings:
event_subscriptions:
request_url: https://api.h852.work/channels/slack/events
bot_events:
- app_mention
- app_uninstalled
- member_joined_channel
- member_left_channel
- message.channels
- message.groups
- message.im
- message.mpim
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
is_mcp_enabled: false

The bot scopes above match SLACK_OAUTH_SCOPES exactly (all 21) โ€” that's required, since the authorize call requests precisely that set and Slack rejects any scope the app doesn't have.

A3. Verify the app + one optional addโ€‹

Creating from A2's manifest leaves nothing mandatory to fix. Just confirm:

  • Event Subscriptions request URL shows "Verified" (the API must already be deployed and reachable โ€” see A7). If it doesn't verify, fix the API origin/reachability first, then re-paste/re-verify.
  • App Home โ†’ Messages tab enabled (already in the manifest).

Optional โ€” read receipts (#1362): the manifest does not subscribe reaction_added, so the delivery "read" receipt path (handleReactionAdded) stays dormant even though reactions:read/write are granted. To enable it, add reaction_added under Event Subscriptions โ†’ bot events and reinstall. Not needed for the core install/binding flow.

A4. Collect credentials from the appโ€‹

From Settings โ†’ Basic Information โ†’ App Credentials:

  • Client ID โ†’ SLACK_CLIENT_ID
  • Client Secret โ†’ SLACK_CLIENT_SECRET
  • Signing Secret โ†’ SLACK_SIGNING_SECRET (used for inbound webhook signature verification; also stored per-install so prod can verify per-org โ€” see completeInstall)

Generate the OAuth state secret:

openssl rand -hex 32 # โ†’ SLACK_OAUTH_STATE_SECRET

A5. Set environment variablesโ€‹

Set on the dev api service (and ensure FRONTEND_URL points at the dev frontend so the callback lands on a real page โ€” see appBaseUrl()). Reference: .env.example:85-102.

SLACK_CLIENT_ID=... # A4
SLACK_CLIENT_SECRET=... # A4
SLACK_SIGNING_SECRET=... # A4
SLACK_OAUTH_STATE_SECRET=... # A4 (openssl rand -hex 32)
PUBLIC_API_URL=https://api.h852.work # canonical API origin (also used by Telegram, webhook-urls)
# FRONTEND_URL / APP_BASE_URL must resolve to the dev frontend (not the API host)

Notes:

  • The OAuth redirect_uri is derived from PUBLIC_API_URL, or https://api.{apex of HWORK_DOMAIN} whenever HWORK_DOMAIN is set. This includes the dev env, whose APP_ENV=development and HWORK_DOMAIN=dev.h852.work: the derivation is gated on HWORK_DOMAIN (not IS_DEPLOYED) and strips the dev./staging. portal-subdomain prefix to the apex, so dev resolves to https://api.h852.work โ€” never http://localhost:3000 and never https://api.dev.h852.work. There is no separate SLACK_OAUTH_REDIRECT_URL env var. The Slack app's redirect_urls allowlist (A2) must include the canonical URL produced by getPublicApiUrl() (#3923).
  • SLACK_OAUTH_STATE_SECRET is validated lazily at call time, not at boot. In dev it has an insecure fallback if unset, so the app still boots; prod throws if missing (getStateSecret). Set a real one in dev anyway to mirror prod.
  • SLACK_BOT_TOKEN in .env.example is the legacy single-token path. With OAuth install you don't set it โ€” the per-org token is written by the install flow. Leave it empty.
  • MASTER_ENCRYPTION_KEY: if set, the stored bot token is AES-256-GCM encrypted; if unset (typical dev), encryptConfig is a passthrough. Either works for dev.

A6. Database tableโ€‹

The flow needs slack_channel_bindings.

  • dev / local / test: TypeORM synchronize: true (non-prod) auto-creates it from the SlackChannelBinding entity โ€” no action needed.

  • staging / prod: run the migration (synchronize is off):

    cd api && npm run migrations:run # applies 1780600000000-AddSlackChannelBindings

    The data source already registers the entity (typeorm-data-source.config.ts); CI's api-postgres-migrations job verifies the forward migration applies cleanly.

A7. Restart & sanity-checkโ€‹

Restart the dev api so the new env vars load. Then verify the Events URL is reachable and the OAuth endpoints exist:

# Slack URL verification handshake (the controller answers the challenge):
curl -sS -X POST https://<API_ORIGIN>/channels/slack/events \
-H 'Content-Type: application/json' \
-d '{"type":"url_verification","challenge":"ping123"}'
# expect 200 with JSON: {"challenge":"ping123"} (see slack.controller.ts:81)

Part B โ€” During Install (per org, the actual install + verification)โ€‹

You're an org member/admin on the dev frontend. (The start endpoint is gated by JwtAuthGuard + org-membership, so you must be logged in to that org โ€” controller.)

B1. Kick off the installโ€‹

  1. Log in to the dev frontend as a member of the target org.
  2. Go to Settings โ†’ Channels (/client/settings/channels).
  3. Click Add to Slack. The frontend calls GET /channels/slack/oauth/start?orgId=โ€ฆ with your Bearer token, gets { url }, and navigates to Slack's authorize screen (startSlackInstall, page.tsx:164).
  4. On Slack, pick the workspace and Allow.

B2. Confirm the callback succeededโ€‹

Slack redirects to โ€ฆ/oauth/callback, which exchanges the code and redirects to /client/settings/channels?slack=connected&team=โ€ฆ. The page shows a post-install banner and a ๐Ÿ’ฌ Message on Slack deep link.

Possible banner states (callback):

Query paramMeaningLikely cause
?slack=connectedsuccessโ€”
?slack=denieduser clicked Cancel on consentretry
?slack=errortoken exchange failedbad SLACK_CLIENT_SECRET, redirect URL mismatch, or missing scope (check api logs: Slack oauth.v2.access not ok: <error>)
Invalid state parameter (plain 400)CSRF state failed/expired (>10 min)restart from B1; check SLACK_OAUTH_STATE_SECRET didn't change mid-flow

DB check:

SELECT org_id, integration_type, status, metadata
FROM integration_credentials
WHERE integration_type = 'slack' AND org_id = '<org_id>';
-- expect status='active', metadata has team_id / team_name / app_id / bot_user_id

B3. Verify inbound + channel bindingโ€‹

  1. In the connected Slack workspace, invite the bot to a channel (/invite @<bot>). This fires member_joined_channel โ†’ the bot auto-binds the channel to the org's primary Specialist and posts a welcome message (slack.service.ts:106).

    If you see no welcome message, the org has no assigned Specialist โ€” the bind is skipped with a warn (Slack channel bind skipped: org=โ€ฆ has no assigned Specialist). Assign a primary Specialist to the org first (AM Setup), then re-invite.

    SELECT org_id, external_channel_id, specialist_id, bound_by_slack_user
    FROM slack_channel_bindings WHERE org_id = '<org_id>';
  2. Post a plain message in the bound channel (no @mention). It should route the exact message and real Slack attachments to the Specialist's existing Hermes session, then deliver the successful canonical reply once in the same thread.

  3. DM the bot (Messages tab). DMs route directly too.

  4. Remove the bot from the channel (/remove @<bot>) โ†’ member_left_channel โ†’ binding row is deleted (slack.service.ts:130).

B4. Outboundโ€‹

Verify the canonical Hermes reply is dispatched automatically. The dispatcher reads the per-org bot_token from the stored credential and posts back to the Slack thread (three-layer protection: withCircuit โ†’ withRetry โ†’ channel_failures DLQ). Separately verify that a genuine Expert follow-up uses the same dispatch path without replacing the already-delivered Hermes reply.


Quick failure mapโ€‹

SymptomCheck
Add to Slack โ†’ 403You're not a member of orgId in your JWT; log in to the right org.
Authorize screen errors on scopesApp's bot scopes don't match SLACK_OAUTH_SCOPES โ€” recreate from the A2 manifest (or add the missing scope + reinstall).
?slack=error after AllowSLACK_CLIENT_SECRET wrong, or the derived redirect URI (getPublicApiUrl() + /channels/slack/oauth/callback) isn't an exact match for a redirect_urls entry on the app (A2). Check api log line Slack oauth.v2.access not ok.
Callback lands on API 404, no bannerFRONTEND_URL not set / points at API host โ€” fix A5 (appBaseUrl).
Events URL won't verify on SlackAPI not publicly reachable, or signature/raw-body issue; confirm A7 handshake returns the challenge.
Inbound messages ignored in a channelChannel not bound and no @mention; either invite the bot (auto-bind) or @mention it.
No welcome on inviteOrg has no primary Specialist โ€” assign one, re-invite.
Works in dev, breaks in prod bootSLACK_OAUTH_STATE_SECRET unset (prod throws); set a real secret.

Promoting to staging / prodโ€‹

Repeat Part A with a separate Slack app and that environment's URLs/secrets, and run the migration (A6) since synchronize is off. Everything else is identical.


Troubleshooting an existing install (prod)โ€‹

Once installed, when a specific org's Slack misbehaves in prod ("installed but nothing happens", connect fails, replies don't return), use the companion runbook โ€” concrete log keywords + DB queries + Sentry filters keyed on org_id / team_id: slack-prod-troubleshooting.md.