Build a production Slack bot with streaming and human escalation
Build a multi-workspace Slack support bot with OAuth installation, Slack-signed Events API and interaction handling, slash commands, durable delivery deduplication, channel allowlists, isolated thread memory, OpenAI streaming through Slack's native chat stream methods, bounded rate-limit retries, explicit two-step human escalation, encrypted installation storage, observability, export, rollback and teardown.
Deploy a Slack app that acknowledges every delivery within Slack's deadline, processes each event once, never crosses workspace/channel/thread boundaries, never exposes reusable tokens or private message bodies in logs, streams bounded assistant output into the correct thread, obeys Web API Retry-After, and performs the only external escalation write after a verified human requester explicitly confirms a durable draft.
- Node.js 22.22.2
- Slack Bolt for JavaScript 4.5.0
- Slack Web API 2026-07 reviewed behavior
- OpenAI JavaScript SDK 7.1.0
- PostgreSQL 16, 17
- Slack app and workspace administration Use a development workspace first. Obtain approval for app name, distribution model, redirect URL, Events API/interactivity endpoint, /support command, app_mention event, exact bot scopes, token rotation, allowed channels, escalation channel, installation owners and uninstall/deletion response.
The manifest requests only app_mentions:read, chat:write and commands. - Public HTTPS and trusted proxy Provide one stable HTTPS origin with a valid chain, unchanged raw request body, strict route and body limits, synchronized clock, no payload logging, reliable /slack/events and OAuth callback routes, and a canary that can be disabled without removing the human-support channel.
Slack Request URL verification and the local signature fixture both pass. - Dedicated database and secret boundary Prepare PostgreSQL with encrypted backups, least-privilege credentials, connection TLS, migration and point-in-time recovery; create a 32-byte installation encryption key plus separate Slack signing, client, OAuth state and OpenAI secrets in the secret manager.
A clean database can apply schema.sql and restore into an isolated drill. - Reviewed product and privacy policy Define approved channels, maximum prompt/context, retention, redaction, moderation and abuse handling, AI disclosure, unsupported-answer behavior, human escalation confirmation, escalation service-level objective, incident contacts, cost/latency gates and whether enterprise installations are supported.
The bot can operate without reading arbitrary channel history or message content intent.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- A reviewable Slack app manifest and multi-workspace OAuth installation path with separate signing, client, state, installation-encryption and OpenAI secrets, token rotation, minimum bot scopes and encrypted PostgreSQL installation storage.
- A signed-delivery Bolt service that acknowledges commands/actions before slow work, relies on Slack's Events API delivery contract, persists one claim per event or trigger, authorizes immutable channel IDs, isolates context by team/channel/thread and never needs broad channel history.
- A bounded OpenAI Responses adapter and Slack-native chat.startStream, chat.appendStream and chat.stopStream lifecycle with typed terminal failures, delta batching, bounded Retry-After handling, explicit incomplete messages, content-free metrics and no model-controlled Slack or escalation tool.
- A durable two-step escalation workflow in which the verified requester creates a draft and explicitly confirms it before one redacted message is written to a fixed human channel, plus tests, canary, backup, export, retention, rollback, uninstall and destructive teardown.
- Every Slack request is authenticated over its exact raw body and fresh timestamp, every command/action acknowledges within three seconds, and every Events API retry or duplicate trigger is claimed once across replicas before provider or Slack side effects.
- Messages from one workspace, channel or thread never appear in another context; only allowlisted channel IDs receive public assistant output; ordinary logs and metrics contain no raw request body, Slack/OpenAI token, email, mention identifier or message text.
- Each accepted app-mention response has one recorded Slack stream timestamp, bounded deltas and a visible completed or incomplete terminal state; slash commands stay one-off and ephemeral; rate-limited Web API methods honor Retry-After without blind replacement streams.
- No model output sends a human escalation. One Slack-verified requester owns a durable draft, must confirm it separately, and produces at most one minimal redacted destination message whose timestamp is reconciled and monitored.
- Operators can restore encrypted installation/state backups, export the approved redacted lifecycle, disable generation while keeping safe acknowledgements, roll back one immutable release, revoke installations, delete retained state and prove that confirmed human obligations were resolved.
Architecture
How the parts fit together
Slack owns user/workspace authentication and sends signed OAuth, Events API, slash-command and Block Kit action traffic through a raw-body-preserving TLS proxy to Bolt. Bolt verifies signatures and OAuth state; the application immediately acknowledges command/action traffic, derives identity and conversation scope from the verified body, and claims a durable delivery key in PostgreSQL. The state layer encrypts installation tokens with AES-GCM, isolates redacted app-mention messages by team/channel/thread, and stores escalation lifecycle with transactional uniqueness. The OpenAI adapter receives only bounded redacted context and emits typed text deltas without tools. Slash commands consume those deltas into one final private response because they have no source-message thread; app mentions provide the required thread and recipient identifiers for one native stream, batched appends, visible stop, Retry-After and the human button. A second verified action confirms a draft before one fixed-channel message. Operations own releases, metrics, backups, exports, reconciliation, credential rotation, uninstall and teardown.
- A workspace administrator installs the reviewed Slack app through OAuth; Bolt validates state and stores encrypted installation data under enterprise/team identity.
- Slack sends a signed command, event or action; the raw body reaches Bolt unchanged, the delivery is acknowledged promptly and the application persists a unique claim before slow work.
- The application authorizes the verified channel ID. A slash command remains a one-off ephemeral request; an app mention records a redacted user message under team/channel/thread and loads only bounded recent context from the same composite key.
- The server-side OpenAI adapter emits typed deltas. Slash commands assemble one final ephemeral replacement. App mentions open one native stream with verified thread and recipient identifiers, aggregate appends, honor Retry-After, stop with a final answer/button and store the assistant result and Slack timestamp.
- A requester who chooses human support creates a durable draft and receives a private confirm action. A second signed action from the same user changes state transactionally and writes one redacted reference to the fixed destination.
- Metrics and operators watch typed lifecycle and human queue state. Backups, exports, rollback and teardown reconcile visible Slack messages, installations, tokens and confirmed obligations instead of assuming database reversal can undo them.
Assumptions
- Slack app administration, workspace installation, approved channel IDs, escalation destination membership, token rotation, request URLs, uninstall responsibility and incident contacts are controlled by named owners.
- The hosting proxy preserves exact Slack raw bodies, provides a valid HTTPS chain, synchronized time, body and connection limits, no payload logging and stable routes that acknowledge within Slack's required deadline.
- PostgreSQL provides TLS, least-privilege roles, migrations outside request handling, encrypted backups, tested isolated restore, durable uniqueness, retention/deletion jobs and enough availability for delivery claims and OAuth fetches.
- Workspace policy permits the bounded redacted thread context to reach the selected OpenAI project under reviewed data controls, and users receive a clear AI/privacy notice before use.
- The reference local code is deployed as one reviewed release; any change to model, prompt, SDK, OAuth scope, channel policy, stream method, retry, retention or escalation destination triggers tests and canary.
- Human support can meet the confirmed escalation SLO and access source threads through Slack authorization without copying full conversations into the destination message.
Key concepts
- Slack signing secret
- A server secret used to verify Slack's v0 HMAC over version, timestamp and exact raw request body. Freshness limits replay. It is distinct from OAuth client, OAuth state, bot token and installation-encryption secrets.
- Acknowledgement
- The prompt HTTP response Slack requires for commands and interactive actions, generally within three seconds. It confirms receipt, not completion; database, OpenAI and Web API work follows after ack.
- Delivery claim
- A durable unique row created from Events API event_id or a verified command/action identity before side effects. It prevents Slack retries and concurrent replicas from producing duplicate provider or message work.
- Installation store
- The application-owned OAuth persistence contract that stores and fetches workspace/enterprise installation data, handles token rotation and deletion, and protects bot/user tokens through encryption and access controls.
- Thread memory key
- The verified team_id, channel_id and thread_ts tuple that scopes retained context. All three remain in reads and writes; the model never selects or expands the key.
- Native Slack stream
- A chat.startStream, chat.appendStream and chat.stopStream lifecycle identified by Slack's returned timestamp. Deltas are batched and every stream reaches a completed or visible incomplete terminal message.
- Retry-After
- The method/workspace-specific delay Slack returns after rate limiting. The caller waits for the bounded value and caps attempts instead of using a fixed blind retry or creating another public stream.
- Escalation draft
- A durable local state owned by the verified requester that causes no external message. Only a second signed action from that requester can confirm it; delivery is then reconciled by unique ID and destination timestamp.
Before you copy
Values used in this guide
{{publicOrigin}}Exact HTTPS origin configured in Slack OAuth and request URLs.
Example: https://bot.example.com{{slackSigningSecret}}secretServer-only secret for signed Slack HTTP requests.
Example: secret-manager-reference{{slackClientId}}secretOAuth client identity for the production Slack app.
Example: 1234567890.1234567890{{slackClientSecret}}secretOAuth client secret stored only on the server.
Example: secret-manager-reference{{slackStateSecret}}secretIndependent random secret protecting OAuth state.
Example: generate-at-least-32-random-bytes{{installationKey}}secretBase64-encoded 32-byte AES-GCM key protecting installation JSON at rest.
Example: secret-manager-reference{{allowedChannelIds}}Comma-separated immutable Slack channel IDs allowed to invoke public answers.
Example: C012SUPPORT,C034HELP{{escalationChannelId}}Fixed reviewed destination for confirmed minimal human-support notices.
Example: C056HUMANS{{model}}Exact OpenAI Responses model evaluated for this bot revision.
Example: gpt-5.4-nano{{databaseUrl}}secretTLS PostgreSQL connection for encrypted installation and durable lifecycle state.
Example: postgresql://slack_bot@postgres.example.net/slack_bot?sslmode=requireSecurity and production boundaries
- Keep Slack signing, OAuth client, OAuth state, installation encryption, bot/user tokens, OpenAI key and database credentials separate and server-only. Never place them in manifest.yaml, Block Kit values, URLs, browser bundles, logs, exports, screenshots or source control.
- Verify Slack requests over the exact raw body and fresh timestamp before trusting team, channel, thread, user, event or action identity. Do not replace Slack authentication with a bearer token inside a message.
- Use least OAuth scopes and immutable channel IDs. This slash-command/app-mention design does not need arbitrary message history or broad user profile/email access.
- Treat Slack message text and OpenAI output as untrusted. Escape rendering, bound size/context/output, minimize retention, disable payload logging and never let text select a channel, Web API method, token, model capability or escalation action.
- No model output performs an external write. Human escalation requires a requester-owned draft and a second signed confirmation; regulated workflows may require a separate on-duty approver.
- Visible Slack messages cannot be rolled back by database reversal. Persist stream and destination timestamps and reconcile ambiguous outcomes before retrying.
Stop before continuing if
- Stop before installation if requested OAuth scopes exceed the documented slash/app-mention use case, request ownership or uninstall responsibility is unclear, or the public proxy cannot preserve exact raw signed bodies.
- Stop before provider integration if Slack context retention, OpenAI data controls, user notice, redaction, deletion, channel policy or cost limits are unapproved.
- Stop before canary if duplicate delivery, cross-team/channel/thread access, stale signature, token plaintext, unknown provider state, unfinished stream or escalation-without-confirmation tests fail.
- Stop before workspace expansion if acknowledgement p95 approaches three seconds, Retry-After is ignored, orphaned streams or duplicate messages occur, confirmed escalations miss SLO, backup/restore fails or uninstall/revocation is not rehearsed.
- Immediately disable generation on suspected token/key exposure, signed-request bypass, cross-workspace disclosure, raw payload logging, unbounded provider cost, unauthorized channel response or external escalation write without verified confirmation.
instruction
Design the Slack identity, channel, event, and action boundary
Document the installation types you support, exact workspace or enterprise identity key, permitted channel IDs, slash and mention entry points, response visibility, thread-key rule, delivery deduplication key, context retention, model capability, escalation states, human confirmer, destination channel and deletion owner. Use identifiers from Slack's verified request body, not user-provided JSON or channel names. Keep the first version slash-command and app-mention only so it does not require broad channel history or privileged message-content access.
Why this step matters
Slack delivers authenticated workspace, channel, user and interaction context, but the application must decide which verified identities may invoke AI, which conversation owns memory, and who may authorize an external escalation. A written boundary prevents feature growth from quietly adding broad scopes or cross-thread state.
What to understand
Use immutable channel IDs in policy. Names can be renamed and duplicate across workspaces. Team or enterprise ID remains part of every state key even if the first installation is a single workspace.
An Events API event_id is Slack's durable retry identity. A slash command does not use event_id, so derive a bounded key from the verified trigger/team/channel/user tuple and persist it before provider work.
The human escalation button is application-authored, but its action still passes through Slack signature verification, verified user ownership, durable draft state and a second explicit confirm step. The model never receives the action tool.
System changes
- Creates the reviewed trust and lifecycle decision; no Slack app, database, provider request or message is created.
SLACK TRUST REVIEW PASSED Installations: workspace only Scopes: app_mentions:read, chat:write, commands Entry points: /support and app_mention Allowed channels: C012SUPPORT, C034HELP Thread key: team_id + channel_id + thread_ts Delivery claim: event_id or hashed trigger tuple External escalation: draft -> requester confirmation -> one post to C056HUMANS Raw message logging: disabled
Checkpoint: Approve the minimum Slack contract
Continue whenOwners agree on installation scope, exact OAuth scopes, signed endpoints, allowed channel IDs, thread isolation, retention, AI disclosure, acknowledgement budget, deduplication, Retry-After policy, escalation confirmation and uninstall/teardown.
Stop whenStop if the use case requires unbounded channel history, user tokens, arbitrary channels, cross-workspace memory, unreviewed enterprise installation, hidden AI behavior or a model-authorized external action.
Security notes
- Never authorize by channel name, display name, email or text supplied inside a message.
- Do not request admin, users:read.email, channels:history or groups:history scopes for a slash-command/app-mention design that does not use them.
Alternatives
- Use an authenticated web support application linked from Slack if message retention or workspace policy cannot support the assistant safely.
Stop conditions
- The organization cannot identify a Slack app owner and incident/uninstall contact.
- The intended feature requires secrets or account actions to pass through model-controlled text.
config
Create the minimum Slack app manifest and development installation
Save manifest.yaml, substitute only the reviewed public origin, create the app in a development workspace, inspect every generated setting, enable token rotation, and install it through Slack OAuth. The manifest declares one slash command, one app_mention subscription, interactivity and three bot scopes. It deliberately leaves Socket Mode and organization-wide deployment disabled. Slack sends Events API, command and action requests to the same Bolt endpoint, where the signing secret authenticates the exact raw request.
Why this step matters
A manifest makes Slack configuration reviewable and reproducible instead of relying on undocumented dashboard clicks. Starting with the smallest scopes reduces the effect of token exposure and proves the product does not need arbitrary workspace history.
What to understand
The OAuth redirect must exactly match the public application route. Use separate Slack apps and secrets for development and production so a test installation cannot access production workspaces.
Slack signs HTTP requests with the app signing secret. OAuth client secret, state secret and installation tokens serve different purposes and must remain separate in the secret manager.
Token rotation changes stored installation data. The installation store must support updates atomically; a hard-coded xoxb token cannot safely represent multiple workspaces or rotation.
System changes
- Creates a Slack application definition and one development-workspace OAuth installation.
- Grants only app mention read, chat write and slash-command handling to the bot within that installation.
Syntax explained
app_mentions:read- Delivers messages that explicitly mention the bot without granting arbitrary history.
chat:write- Allows the bot to create and stream reviewed responses and confirmed escalation notices.
commands- Enables the /support slash command.
token_rotation_enabled- Lets Slack rotate OAuth tokens through the installation lifecycle.
manifest.yaml_metadata:
major_version: 2
display_information:
name: Source-cited Support Assistant
description: Answers approved support questions and offers confirmed human escalation.
features:
bot_user:
display_name: Support Assistant
always_online: false
slash_commands:
- command: /support
url: https://bot.example.com/slack/events
description: Ask the approved support assistant
usage_hint: describe the support question without secrets
should_escape: false
oauth_config:
redirect_urls:
- https://bot.example.com/slack/oauth_redirect
scopes:
bot:
- app_mentions:read
- chat:write
- commands
settings:
event_subscriptions:
request_url: https://bot.example.com/slack/events
bot_events:
- app_mention
interactivity:
is_enabled: true
request_url: https://bot.example.com/slack/events
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: trueSlack app created: Source-cited Support Assistant Development workspace installed: T012DEVELOP Bot scopes: app_mentions:read, chat:write, commands Event subscription: app_mention Request URL: verified Interactivity URL: verified Token rotation: enabled Organization deploy: disabled
Checkpoint: Review the installed app permissions
Continue whenThe Slack dashboard and OAuth grant show only the three approved bot scopes, the exact HTTPS callback and request URLs verify, token rotation is enabled, and no production workspace is installed.
Stop whenStop on an unexpected scope, user token, organization deploy, Socket Mode, alternate callback, invalid TLS chain, unowned production installation or request endpoint that logs bodies.
If this step fails
Slack rejects the Events API or interactive request URL.
Likely causeThe public endpoint is not valid HTTPS, the certificate chain is incomplete, the route is wrong, the server is unavailable, or the app did not return the expected challenge or acknowledgement quickly enough.
Check the exact request URL, TLS chain, proxy route and application health without logging request bodies or signing secrets.Inspect Slack delivery status, request timestamp, event ID and sanitized response latency; confirm the listener acknowledges before provider work.
ResolutionRestore the known-good HTTPS route and fast acknowledgement path, then use Slack's app configuration retry. Keep AI generation outside the acknowledgement deadline and do not disable signature verification to make the check pass.
OAuth installation succeeds but the bot cannot fetch the installation later.
Likely causeEnterprise and team keys were derived inconsistently, installation data failed encryption/decryption, token rotation updated only memory, or the OAuth callback wrote to a different database.
Inspect only enterprise/team key, installed/updated timestamps, ciphertext presence and token-rotation callback status; never print decrypted installation JSON.Use a synthetic installation fixture to round-trip AES-GCM under the configured 32-byte key.
ResolutionRestore the matching encryption key and database, correct enterprise/team key derivation, reinstall the app if token state cannot be proven, and revoke orphaned installations. Do not fall back to a single hard-coded bot token.
Security notes
- Store the manifest in source control but never place signing, client, state, installation or provider secrets inside it.
Alternatives
- Use Socket Mode only for a separately reviewed internal deployment that cannot expose HTTPS; the authentication, acknowledgement, dedupe, state and escalation controls still apply.
Stop conditions
- The workspace cannot approve the minimum scopes or token rotation lifecycle.
config
Scaffold the pinned TypeScript Slack service
Create a private repository, save package.json and the same strict NodeNext tsconfig pattern used by the website bot, run npm install, commit package-lock.json, and keep .env, exports, database dumps and payload fixtures ignored. The direct dependencies cover Bolt request routing/OAuth, typed Web API calls, the OAuth installation contract, OpenAI Responses streaming, PostgreSQL state and Zod validation. Separate start, admin, test and type-check commands prevent an export or destructive teardown from being reachable through the public Bolt process.
Why this step matters
Slack, OpenAI and database SDK behavior all affect authentication, retry, streaming and state. A pinned runtime and lockfile make those changes explicit, while a separate admin entry point reduces the risk that public event handling can invoke export or teardown code.
What to understand
Use npm ci for CI and deployment and review lockfile diffs. Re-run signature, tenant/thread, dedupe, Retry-After, stream terminal-state, OAuth round-trip and escalation state tests after any direct or transitive update.
Do not store captured Slack request bodies as fixtures. Build synthetic form and JSON payloads with test-only identifiers and secrets, then calculate valid signatures locally.
Compile and test in a network-isolated stage before injecting Slack, OpenAI or database credentials. A package install script must never run with production secrets available.
System changes
- Creates project manifests, a lockfile and dependency tree; no Slack message, OAuth installation or OpenAI request is produced by this step.
Syntax explained
@slack/bolt- Provides the verified Slack HTTP, OAuth, Events API, commands and interactivity application framework.
@slack/oauth- Provides the typed durable installation-store contract.
private: true- Prevents accidental npm publication of the application package.
package.json{
"name": "production-slack-support-bot",
"private": true,
"type": "module",
"engines": { "node": "22.22.2" },
"scripts": {
"start": "tsx src/app.ts",
"admin": "tsx src/admin.ts",
"test": "node --import tsx --test test/*.test.ts",
"check": "tsc --noEmit"
},
"dependencies": {
"@slack/bolt": "4.5.0",
"@slack/oauth": "3.0.4",
"@slack/web-api": "7.12.0",
"openai": "7.1.0",
"pg": "8.16.3",
"zod": "4.4.3"
},
"devDependencies": {
"@types/node": "22.19.19",
"@types/pg": "8.15.5",
"tsx": "4.23.1",
"typescript": "5.9.3"
}
}added 164 packages, and audited 165 packages in 3s found 0 vulnerabilities $ npm run check TypeScript: 0 errors $ npm test # tests 3 # pass 3 # fail 0
Checkpoint: Prove a clean reproducible project
npm ci && npm run check && npm testContinue whenThe exact dependency graph installs under Node 22.22.2, strict TypeScript passes and all synthetic tests run without network credentials.
Stop whenStop on an unreviewed package change, install script, audit exception, type error, payload fixture containing real workspace data or secret found in the repository.
Security notes
- Review SDK changelogs for OAuth, raw-body verification, token rotation and streaming behavior before upgrading.
Alternatives
- Use the organization's approved Java, Python or Go Slack SDK while preserving the same signed-request, OAuth store, dedupe, state, stream and human-gate contracts.
Stop conditions
- The clean build cannot reproduce the reviewed dependency and test results.
config
Document Slack, OpenAI, state, and safety configuration
Save the shown template as .env.example and keep real values outside the repository. Resolve signing, OAuth client, state, installation-encryption, OpenAI, and database credentials from separate secret-manager entries. Review the exact public origin, bot scopes, immutable channel IDs, escalation destination, prompt/context limits, and native-stream flush interval before installing the app. The template is required by the runnable service, policy schema, OAuth store, Bolt application, admin tool, systemd unit, backup, and rotation procedures.
Why this step matters
The implementation validates environment data but cannot supply the deployment contract by itself. A complete non-secret template shows operators which independent credentials and bounds exist, prevents the systemd EnvironmentFile from becoming undocumented configuration, and makes app-manifest scopes, channel policy, database state, model use, and native streaming reviewable together.
What to understand
SLACK_SIGNING_SECRET authenticates incoming request bytes; SLACK_CLIENT_SECRET authenticates OAuth exchange; SLACK_STATE_SECRET protects the OAuth redirect; and SLACK_INSTALLATION_KEY_BASE64 encrypts stored installation tokens. They are separate roles and must not reuse one value.
ALLOWED_CHANNEL_IDS and ESCALATION_CHANNEL_ID use immutable Slack IDs. A display name, user-provided channel name, model output, block value, or request query cannot extend either set.
The database URL represents the runtime role, not the migration or backup owner. OPENAI_API_KEY belongs to a restricted provider project with budget and data controls; neither credential appears in Slack messages, exports, tests, logs, or app manifests.
System changes
- Creates a non-secret .env.example inventory for development and deployment.
- Creates no Slack installation, database row, provider request, message, escalation, or real credential.
Syntax explained
SLACK_STATE_SECRET- Binds OAuth installation callbacks to the initiating browser state.
SLACK_INSTALLATION_KEY_BASE64- Provides exactly 32 decoded bytes for AES-256-GCM installation encryption.
STREAM_FLUSH_MS- Bounds how frequently accumulated provider deltas are appended to a native Slack stream.
.env.examplePORT=3000
PUBLIC_ORIGIN=https://bot.example.com
SLACK_SIGNING_SECRET=store-in-secret-manager
SLACK_CLIENT_ID=store-in-secret-manager
SLACK_CLIENT_SECRET=store-in-secret-manager
SLACK_STATE_SECRET=store-at-least-32-random-bytes
SLACK_INSTALLATION_KEY_BASE64=store-base64-encoded-32-random-bytes
SLACK_SCOPES=app_mentions:read,chat:write,commands
ALLOWED_CHANNEL_IDS=C012SUPPORT,C034HELP
ESCALATION_CHANNEL_ID=C056HUMANS
OPENAI_API_KEY=store-in-secret-manager
OPENAI_MODEL=gpt-5.4-nano
DATABASE_URL=postgresql://slack_bot:secret@postgres.example.net/slack_bot?sslmode=require
MAX_PROMPT_CHARS=4000
MAX_CONTEXT_MESSAGES=20
STREAM_FLUSH_MS=450SLACK ENVIRONMENT REVIEW PASSED public_origin=https://bot.example.com scopes=app_mentions:read,chat:write,commands allowed_channels=2 escalation_channel=1 max_prompt=4000 max_context=20 stream_flush_ms=450 secret_values=resolved_at_runtime tracked_secret_files=0
Checkpoint: Review configuration separation
Continue whenAll variables have an owner, real secrets resolve outside source control, the public origin matches the Slack manifest, exact scopes and channels match the approved policy, numeric bounds are safe, and database, provider, installation, signing, OAuth, and state credentials remain independent.
Stop whenStop if an example placeholder could work as a production secret, a public request selects channel or model policy, one credential spans roles, or the runtime uses a database migration owner.
Security notes
- Startup must reject placeholder values and log only missing-variable names, never secret values.
Alternatives
- Render the same validated contract directly from a workload secret and policy system, while keeping this non-secret inventory in source control.
Stop conditions
- The deployment platform cannot rotate a Slack or OpenAI credential without rebuilding the image or exposing it to another role.
config
Migrate durable installation, delivery, thread, and escalation state
Apply schema.sql with a dedicated migration role, then grant the runtime only the needed SELECT, INSERT, UPDATE and limited DELETE permissions. The schema stores encrypted installation payloads keyed by enterprise/team identity, delivery claims with a unique primary key, redacted conversation messages keyed by team/channel/thread, and escalation drafts with a unique operation key and explicit draft, confirmed, delivered, cancelled or failed lifecycle. Back up the database before every migration and restore it into an isolated environment before production.
Why this step matters
Slack retries and multiple replicas make process memory insufficient for deduplication, OAuth token rotation, thread context and action lifecycle. Database uniqueness and explicit state transitions let the application reconcile ambiguous network outcomes and prove which verified human confirmed an escalation.
What to understand
The reference stores only redacted text for bounded context. Decide whether this retention is permitted, set a short expiration job, encrypt disks and backups, audit access, and support deletion by workspace/channel/thread without relying on model memory.
Installation ciphertext still contains sensitive OAuth tokens. Encryption at the application layer limits casual database exposure, but key access, backup encryption and Slack revocation remain necessary.
Use a migration lock and transactional DDL appropriate to the production platform. Never create or alter tables during a Slack request or process startup.
System changes
- Creates persistent database tables and indexes for OAuth installations, retries, redacted thread memory and escalation state.
- Does not yet store a real installation or message; a migration changes production database schema and requires a tested restore.
Syntax explained
PRIMARY KEY delivery_id- Lets all replicas claim one Slack delivery exactly once.
UNIQUE operation_key- Makes creation of one escalation draft idempotent.
team_id + channel_id + thread_ts- Defines the minimum conversation isolation key.
db/schema.sqlBEGIN;
CREATE TABLE IF NOT EXISTS slack_installations (
enterprise_key text NOT NULL,
team_key text NOT NULL,
encrypted_installation text NOT NULL,
installed_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (enterprise_key, team_key)
);
CREATE TABLE IF NOT EXISTS slack_deliveries (
delivery_id text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('event','command','action')),
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS slack_messages (
id bigserial PRIMARY KEY,
team_id text NOT NULL,
channel_id text NOT NULL,
thread_ts text NOT NULL,
user_id text,
role text NOT NULL CHECK (role IN ('user','assistant')),
text_redacted text NOT NULL CHECK (char_length(text_redacted) <= 8000),
slack_ts text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS slack_messages_thread_idx
ON slack_messages (team_id, channel_id, thread_ts, id DESC);
CREATE TABLE IF NOT EXISTS slack_escalations (
id uuid PRIMARY KEY,
operation_key text NOT NULL UNIQUE,
team_id text NOT NULL,
channel_id text NOT NULL,
thread_ts text NOT NULL,
requester_id text NOT NULL,
summary_redacted text NOT NULL CHECK (char_length(summary_redacted) <= 2000),
status text NOT NULL CHECK (status IN ('draft','confirmed','delivered','cancelled','failed')),
destination_ts text,
created_at timestamptz NOT NULL DEFAULT now(),
confirmed_at timestamptz,
delivered_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS slack_escalations_status_idx
ON slack_escalations (status, created_at);
COMMIT;BEGIN CREATE TABLE CREATE TABLE CREATE TABLE CREATE INDEX CREATE TABLE CREATE INDEX COMMIT Schema: slack_installations, slack_deliveries, slack_messages, slack_escalations Duplicate delivery insert: 0 rows Cross-thread query fixture: 0 foreign rows
Checkpoint: Verify schema invariants and restore
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f db/schema.sqlContinue whenThe migration commits once, duplicate delivery and operation keys are rejected, context queries require team/channel/thread, escalation states are constrained, runtime grants are narrow, and the pre-migration backup restores in isolation.
Stop whenStop if migration runs with superuser runtime credentials, backup/restore is untested, text has no retention owner, a uniqueness constraint is absent, or tenant/thread isolation is not represented in every query key.
If this step fails
The same mention or command produces duplicate replies.
Likely causeSlack retried an unacknowledged Events API delivery, the application did not persist event_id or the command delivery key, or two replicas claimed the same delivery without a database uniqueness constraint.
Compare Slack event_id, retry headers, trigger ID, team/channel/user identifiers and the slack_deliveries row.Confirm INSERT ... ON CONFLICT DO NOTHING occurs before OpenAI or message creation and that all replicas share the same database.
ResolutionKeep the first durable claim, suppress later deliveries, reconcile any already-created streams by Slack message timestamp, and add the exact retry case to tests. Do not deduplicate by message text.
Thread memory includes another channel, workspace or conversation.
Likely causeThe database query omitted team_id, channel_id or thread_ts, a slash-command pseudo-thread collided, or client-supplied identifiers were trusted instead of the verified Slack body.
Inspect the exact composite key and row counts with redacted text, then run two-workspace and two-thread isolation fixtures.Confirm slash commands stay one-off and ephemeral, while app mentions persist only the event's verified team/channel/thread key.
ResolutionDisable the bot, preserve access evidence, remove cross-boundary records under incident handling, fix the composite query and rerun tenant/thread isolation tests before restoring traffic. Treat exposed content as a privacy incident.
Security notes
- Database administrators can still access redacted context and encrypted installations; restrict roles and audit exports.
- Do not use the same database or encryption key across development and production Slack apps.
Alternatives
- Use an approved transactional key-value/document store only if it provides conditional create, consistent composite queries, encryption, backup, retention and deletion evidence.
Stop conditions
- The store cannot provide durable uniqueness across every application replica.
- The organization cannot approve retention and deletion of Slack context.
config
Implement signed-request, redaction, channel, and retry policy
Save the shown module as src/policy.ts. It validates every environment name and bound, provides a deterministic Slack v0 signature verifier over the exact timestamp and raw body, rejects requests outside the five-minute replay window, removes reusable token, email, and mention identifiers from retained text, escapes Slack control characters, parses only immutable channel IDs, bounds prompts, and retries only documented Slack rate-limit failures using a capped Retry-After. Bolt remains the live receiver and performs its own official signature verification over the unchanged raw body; this helper makes the security contract directly testable.
Why this step matters
The state, assistant, app, and tests import this module, so leaving it implicit makes the reference fail at both type checking and runtime. Keeping signature, redaction, immutable-channel, prompt, and bounded retry behavior in one visible policy file also prevents each listener from inventing a slightly different trust boundary.
What to understand
Slack's signature base string is `v0:timestamp:rawBody`. Parsing form or JSON input and then serializing it again changes the signed bytes. The local helper is for fixtures; do not place a body parser ahead of Bolt's live receiver.
Redaction reduces retained content but does not prove it is non-sensitive. Apply the approved short retention, access audit, export filter, and deletion procedure to redacted thread state.
Retry only a documented `ratelimited` or `rate_limited` response with a positive bounded delay. An accepted-then-timeout message or escalation is ambiguous and must be reconciled by its stored Slack timestamp or operation ID rather than replayed.
System changes
- Creates src/policy.ts used by state, assistant, Bolt routes, administration, and tests.
- Performs no network call or external write until a caller explicitly invokes the bounded Slack API wrapper.
Syntax explained
v0:timestamp:rawBody- Matches Slack's documented signing base string over exact request bytes.
timingSafeEqual- Compares equal-length signature buffers without ordinary early-exit equality.
bounded Retry-After- Honors Slack's rate-limit delay while capping wait and attempts.
src/policy.tsimport { createHmac, timingSafeEqual } from "node:crypto";
import { z } from "zod";
export const environmentSchema = z.object({
PORT: z.coerce.number().int().min(1024).max(65535).default(3000),
PUBLIC_ORIGIN: z.string().url(),
SLACK_SIGNING_SECRET: z.string().min(20),
SLACK_CLIENT_ID: z.string().min(3),
SLACK_CLIENT_SECRET: z.string().min(20),
SLACK_STATE_SECRET: z.string().min(32),
SLACK_INSTALLATION_KEY_BASE64: z.string().min(40),
SLACK_SCOPES: z.string().min(3),
ALLOWED_CHANNEL_IDS: z.string().min(1),
ESCALATION_CHANNEL_ID: z.string().regex(/^[CG][A-Z0-9]+$/),
OPENAI_API_KEY: z.string().min(20),
OPENAI_MODEL: z.string().min(3),
DATABASE_URL: z.string().min(12),
MAX_PROMPT_CHARS: z.coerce.number().int().min(128).max(8000).default(4000),
MAX_CONTEXT_MESSAGES: z.coerce.number().int().min(2).max(40).default(20),
STREAM_FLUSH_MS: z.coerce.number().int().min(250).max(2000).default(450)
});
export type Environment = z.infer<typeof environmentSchema>;
export function verifySlackSignature(
signingSecret: string,
timestamp: string,
rawBody: string,
signature: string,
nowSeconds = Math.floor(Date.now() / 1000)
): boolean {
const numericTimestamp = Number(timestamp);
if (!Number.isFinite(numericTimestamp) || Math.abs(nowSeconds - numericTimestamp) > 300) return false;
const expected = "v0=" + createHmac("sha256", signingSecret)
.update("v0:" + timestamp + ":" + rawBody)
.digest("hex");
const left = Buffer.from(expected);
const right = Buffer.from(signature);
return left.length === right.length && timingSafeEqual(left, right);
}
export function redactSlackText(value: string): string {
return value
.replace(/xox[baprs]-[A-Za-z0-9-]+/g, "[REDACTED_SLACK_TOKEN]")
.replace(/sk-[A-Za-z0-9_-]{12,}/g, "[REDACTED_OPENAI_KEY]")
.replace(/bearer\s+[A-Za-z0-9._~-]+/gi, "Bearer [REDACTED]")
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[REDACTED_EMAIL]")
.replace(/<@([A-Z0-9]+)>/g, "@user");
}
export function safeSlackMarkdown(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
export function allowedChannelSet(value: string): Set<string> {
return new Set(value.split(",").map((item) => item.trim()).filter((item) => /^[CG][A-Z0-9]+$/.test(item)));
}
export function boundedPrompt(raw: string, maximum: number): string {
const value = raw.replace(/<@[A-Z0-9]+>/g, "").trim();
if (value.length < 3 || value.length > maximum) throw new Error("prompt_out_of_bounds");
return value;
}
export async function sleep(milliseconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
}
export async function slackApiCall<T>(
call: () => Promise<T>,
attempts = 3
): Promise<T> {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await call();
} catch (error) {
const failure = error as {
data?: { retry_after?: number; error?: string };
retryAfter?: number;
};
const retryAfter = Number(failure.retryAfter ?? failure.data?.retry_after ?? 0);
const code = failure.data?.error;
if (!["ratelimited", "rate_limited"].includes(code ?? "") || !retryAfter || attempt === attempts) throw error;
await sleep(Math.min(retryAfter, 30) * 1000);
}
}
throw new Error("slack_retry_exhausted");
}SLACK POLICY FIXTURE PASS valid_v0_signature=true altered_raw_body=false stale_timestamp=false allowed_channels=C012SUPPORT,C034HELP prompt_4000=accepted prompt_4001=rejected ratelimited_retry_after=honored max_attempts=3 secret_patterns_in_redacted_output=0
Checkpoint: Pass request-policy fixtures
npm run check && npm testContinue whenThe imported policy module type-checks; valid current raw-body signatures pass; altered, malformed, or stale signatures fail; secrets and identifiers are minimized; channel and prompt bounds hold; and rate-limit retry stops at the configured attempt cap.
Stop whenStop if the live receiver sees transformed bytes, a stale request passes, comparison lengths leak acceptance, an unknown channel is retained, or retry handles a non-rate-limit or ambiguous write as safe.
Security notes
- Never log the raw request, signing secret, calculated HMAC, OAuth code, installation object, provider key, or unredacted Slack text while diagnosing a failed fixture.
Alternatives
- Use equivalent organization-owned helpers only when Bolt still receives exact bytes and the same fixtures prove freshness, redaction, channel, prompt, and retry contracts.
Stop conditions
- The trusted proxy cannot preserve exact Slack request bytes or a synchronized clock.
config
Implement encrypted OAuth installation storage
With src/policy.ts already verified, save src/state.ts. The state adapter imports the validated environment and redaction contract, encrypts the complete installation JSON with AES-256-GCM, derives stable enterprise/team keys, atomically upserts token rotation, and never returns ciphertext or plaintext through a public route. Bolt continues to use the signing secret to verify exact raw request bodies, while the state module owns installation confidentiality, delivery claims, thread scope, and escalation lifecycle.
Why this step matters
Slack signature verification authenticates incoming HTTP deliveries, OAuth state protects installation redirects, and encrypted installation storage protects long-lived workspace tokens at rest. They are independent controls; using one secret or one hard-coded bot token for all roles breaks multi-workspace isolation and rotation.
What to understand
Bolt must receive the exact raw body. Configure the proxy not to parse, normalize or reserialize Slack payloads before the framework verifier. The local helper exists for deterministic regression testing, not as a second competing live parser.
AES-GCM requires a unique random nonce per encryption and a protected 32-byte key. Losing the key makes installations unavailable; exposing it plus database ciphertext exposes tokens. Back it up separately under dual-controlled secret recovery.
InstallationStore keys must handle workspace and enterprise installs deliberately. This first release blocks organization-wide deploy, but the key shape and tests prevent a later enterprise addition from colliding with workspace rows.
System changes
- Adds encrypted installation persistence, durable delivery/message/escalation operations, request-policy helpers and a provider-independent Slack signature test.
- OAuth installation and token rotation write encrypted Slack credentials to PostgreSQL; the public request path cannot export them.
Syntax explained
AES-256-GCM- Encrypts installation JSON with authenticated ciphertext and a random nonce.
v0:timestamp:rawBody- The exact Slack signature base string; parsed values are not equivalent.
five-minute window- Rejects replayed valid signatures after the documented freshness boundary.
src/state.tsimport { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
import { Pool, type PoolClient } from "pg";
import type { Installation, InstallationQuery, InstallationStore } from "@slack/oauth";
import { environmentSchema, redactSlackText } from "./policy.js";
const env = environmentSchema.parse(process.env);
const pool = new Pool({
connectionString: env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000
});
const key = Buffer.from(env.SLACK_INSTALLATION_KEY_BASE64, "base64");
if (key.length !== 32) throw new Error("SLACK_INSTALLATION_KEY_BASE64 must decode to 32 bytes.");
function seal(value: unknown): string {
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
const plaintext = Buffer.from(JSON.stringify(value), "utf8");
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return [nonce, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64url")).join(".");
}
function open<T>(value: string): T {
const [nonceRaw, tagRaw, encryptedRaw] = value.split(".");
if (!nonceRaw || !tagRaw || !encryptedRaw) throw new Error("invalid_encrypted_installation");
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonceRaw, "base64url"));
decipher.setAuthTag(Buffer.from(tagRaw, "base64url"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(encryptedRaw, "base64url")),
decipher.final()
]);
return JSON.parse(plaintext.toString("utf8")) as T;
}
function keys(input: Installation | InstallationQuery<boolean>): [string, string] {
const enterprise = "enterpriseId" in input ? input.enterpriseId : input.enterprise?.id;
const team = "teamId" in input ? input.teamId : input.team?.id;
return [enterprise ?? "-", team ?? "-"];
}
export const installationStore: InstallationStore = {
async storeInstallation(installation) {
const [enterpriseKey, teamKey] = keys(installation);
await pool.query(
`INSERT INTO slack_installations (enterprise_key, team_key, encrypted_installation)
VALUES ($1, $2, $3)
ON CONFLICT (enterprise_key, team_key) DO UPDATE SET
encrypted_installation = EXCLUDED.encrypted_installation,
updated_at = now()`,
[enterpriseKey, teamKey, seal(installation)]
);
},
async fetchInstallation(query) {
const [enterpriseKey, teamKey] = keys(query);
const result = await pool.query<{ encrypted_installation: string }>(
"SELECT encrypted_installation FROM slack_installations WHERE enterprise_key = $1 AND team_key = $2",
[enterpriseKey, teamKey]
);
if (!result.rows[0]) throw new Error("slack_installation_not_found");
return open<Installation>(result.rows[0].encrypted_installation);
},
async deleteInstallation(query) {
const [enterpriseKey, teamKey] = keys(query);
await pool.query(
"DELETE FROM slack_installations WHERE enterprise_key = $1 AND team_key = $2",
[enterpriseKey, teamKey]
);
}
};
export async function claimDelivery(deliveryId: string, kind: "event" | "command" | "action"): Promise<boolean> {
const result = await pool.query(
"INSERT INTO slack_deliveries (delivery_id, kind) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[deliveryId, kind]
);
return result.rowCount === 1;
}
export async function appendMessage(input: {
teamId: string;
channelId: string;
threadTs: string;
userId?: string;
role: "user" | "assistant";
text: string;
slackTs?: string;
}): Promise<void> {
await pool.query(
`INSERT INTO slack_messages
(team_id, channel_id, thread_ts, user_id, role, text_redacted, slack_ts)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
input.teamId,
input.channelId,
input.threadTs,
input.userId ?? null,
input.role,
redactSlackText(input.text).slice(0, 8000),
input.slackTs ?? null
]
);
}
export async function loadThread(
teamId: string,
channelId: string,
threadTs: string,
limit: number
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
const result = await pool.query<{ role: "user" | "assistant"; text_redacted: string }>(
`SELECT role, text_redacted FROM (
SELECT id, role, text_redacted FROM slack_messages
WHERE team_id = $1 AND channel_id = $2 AND thread_ts = $3
ORDER BY id DESC LIMIT $4
) recent ORDER BY id ASC`,
[teamId, channelId, threadTs, limit]
);
return result.rows.map((row) => ({ role: row.role, text: row.text_redacted }));
}
export async function createEscalationDraft(input: {
operationKey: string;
teamId: string;
channelId: string;
threadTs: string;
requesterId: string;
summary: string;
}): Promise<string> {
const id = randomUUID();
const result = await pool.query<{ id: string }>(
`INSERT INTO slack_escalations
(id, operation_key, team_id, channel_id, thread_ts, requester_id, summary_redacted, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'draft')
ON CONFLICT (operation_key) DO UPDATE SET updated_at = now()
RETURNING id`,
[
id,
input.operationKey,
input.teamId,
input.channelId,
input.threadTs,
input.requesterId,
redactSlackText(input.summary).slice(0, 2000)
]
);
return result.rows[0]!.id;
}
export async function confirmEscalation(
id: string,
requesterId: string
): Promise<{ id: string; teamId: string; channelId: string; threadTs: string; requesterId: string; summary: string; status: string }> {
return transaction(async (client) => {
const result = await client.query<{
id: string; team_id: string; channel_id: string; thread_ts: string;
requester_id: string; summary_redacted: string; status: string;
}>(
"SELECT * FROM slack_escalations WHERE id = $1 FOR UPDATE",
[id]
);
const row = result.rows[0];
if (!row || row.requester_id !== requesterId) throw new Error("escalation_not_owned");
if (row.status === "draft") {
await client.query(
"UPDATE slack_escalations SET status = 'confirmed', confirmed_at = now(), updated_at = now() WHERE id = $1",
[id]
);
} else if (!["confirmed", "delivered"].includes(row.status)) {
throw new Error("escalation_not_confirmable");
}
return {
id: row.id,
teamId: row.team_id,
channelId: row.channel_id,
threadTs: row.thread_ts,
requesterId: row.requester_id,
summary: row.summary_redacted,
status: row.status === "draft" ? "confirmed" : row.status
};
});
}
export async function cancelEscalation(id: string, requesterId: string): Promise<boolean> {
const result = await pool.query(
`UPDATE slack_escalations SET status = 'cancelled', updated_at = now()
WHERE id = $1 AND requester_id = $2 AND status = 'draft'`,
[id, requesterId]
);
return result.rowCount === 1;
}
export async function markEscalationDelivered(id: string, destinationTs: string): Promise<void> {
await pool.query(
`UPDATE slack_escalations SET status = 'delivered', destination_ts = $2,
delivered_at = now(), updated_at = now()
WHERE id = $1 AND status = 'confirmed'`,
[id, destinationTs]
);
}
async function transaction<T>(work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await work(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function exportState(): Promise<unknown> {
const [installations, deliveries, messages, escalations] = await Promise.all([
pool.query("SELECT enterprise_key, team_key, installed_at, updated_at FROM slack_installations ORDER BY team_key"),
pool.query("SELECT delivery_id, kind, received_at FROM slack_deliveries ORDER BY received_at"),
pool.query("SELECT team_id, channel_id, thread_ts, role, text_redacted, slack_ts, created_at FROM slack_messages ORDER BY id"),
pool.query("SELECT id, operation_key, team_id, channel_id, thread_ts, requester_id, summary_redacted, status, destination_ts, created_at, confirmed_at, delivered_at FROM slack_escalations ORDER BY created_at")
]);
return {
exportedAt: new Date().toISOString(),
installations: installations.rows,
deliveries: deliveries.rows,
messages: messages.rows,
escalations: escalations.rows
};
}
export async function teardownDatabase(confirm: string): Promise<void> {
if (confirm !== "DELETE-SLACK-BOT-STATE") throw new Error("destructive_confirmation_required");
await pool.query("TRUNCATE slack_messages, slack_deliveries, slack_escalations, slack_installations");
await pool.end();
}
OAuth callback: 302 -> Slack success Installation stored: enterprise=- team=T012DEVELOP ciphertext=yes Plain xoxb token in database/logs: 0 Token rotation update: installation updated_at advanced Known signature fixture: accepted Altered body fixture: rejected Stale timestamp fixture: rejected
Checkpoint: Pass OAuth, encryption, signature, and replay tests
npm run check && npm testContinue whenSynthetic installations round-trip under the configured key, enterprise/team keys do not collide, token rotation replaces ciphertext atomically, valid raw bodies pass, altered or stale bodies fail and no plaintext token appears in database inspection or logs.
Stop whenStop if Bolt receives a transformed body, the signing secret or encryption key is shared with another role, plaintext tokens are stored/logged, clock drift exceeds the replay window, or installation deletion cannot identify the exact workspace.
If this step fails
Valid-looking Slack requests fail signature verification.
Likely causeThe application verified parsed form fields instead of the exact raw body, used the wrong signing secret, allowed a proxy to rewrite the body, compared the wrong version prefix, or the host clock differs by more than the replay window.
Compare the request timestamp, body byte length, signature version, trusted proxy behavior and service clock; never print the raw signed body.Run the local known-signature fixture and confirm Bolt receives the untouched body on /slack/events.
ResolutionRestore the current signing secret from the secret manager, preserve the raw body through the proxy, synchronize the clock and redeploy the reviewed verifier. Rotate the secret if exposure is suspected; never accept unsigned or stale deliveries.
OAuth installation succeeds but the bot cannot fetch the installation later.
Likely causeEnterprise and team keys were derived inconsistently, installation data failed encryption/decryption, token rotation updated only memory, or the OAuth callback wrote to a different database.
Inspect only enterprise/team key, installed/updated timestamps, ciphertext presence and token-rotation callback status; never print decrypted installation JSON.Use a synthetic installation fixture to round-trip AES-GCM under the configured 32-byte key.
ResolutionRestore the matching encryption key and database, correct enterprise/team key derivation, reinstall the app if token state cannot be proven, and revoke orphaned installations. Do not fall back to a single hard-coded bot token.
Security notes
- Do not log Bolt arguments, raw bodies, installation objects, OAuth codes or Slack Web API errors that may contain request data.
- Rotate and reinstall affected Slack credentials after suspected signing, client, state, installation-key or token exposure.
Alternatives
- Store each OAuth token as a dedicated secret-manager object referenced by installation metadata when the platform supports atomic token rotation and audited deletion.
Stop conditions
- The live route cannot preserve the exact signed bytes.
- The encryption key cannot be recovered securely or rotated through a tested reinstall plan.
config
Implement the bounded OpenAI Responses stream
Save src/assistant.ts. It receives only redacted messages already isolated to one team/channel/thread, converts them to the supported input-message shape, fixes a reviewed server-side model and instruction, enables streaming, caps output, yields only response.output_text.delta events, and treats response.failed or response.incomplete as failures. It exposes no tool, arbitrary URL, Slack client, escalation function or token to the model. A caller must own Slack message lifecycle and present a clear incomplete state.
Why this step matters
Separating the provider stream from Slack delivery keeps model behavior unable to select workspace, channel, message method, retry policy or escalation. Typed terminal events prevent partial output from being stored as a normal completed answer.
What to understand
Thread context is bounded by message count and redacted before the adapter. The database query, not the model, determines the conversation scope. A user cannot ask the model to load another channel.
Streaming reduces perceived latency but adds partial-state recovery. The Slack layer records the stream timestamp, aggregates deltas and must end the message with either completed content or a visible incomplete notice.
A low output cap and one provider retry bound cost, but ambiguous provider failures still need metrics and an explicit user retry. Never launch a second public Slack stream automatically after partial content was visible.
System changes
- Adds server-side OpenAI Responses streaming for one bounded redacted context.
- Creates provider generation requests but has no Slack or other external write capability.
Syntax explained
stream: true- Returns typed incremental Responses events for Slack stream appends.
max_output_tokens: 700- Caps generated text and provider cost to the evaluated budget.
no tools- Prevents the model from selecting a Slack, network or escalation action.
src/assistant.tsimport OpenAI from "openai";
import { environmentSchema } from "./policy.js";
const env = environmentSchema.parse(process.env);
const client = new OpenAI({ apiKey: env.OPENAI_API_KEY, timeout: 25_000, maxRetries: 1 });
export async function* streamAssistant(
messages: Array<{ role: "user" | "assistant"; text: string }>
): AsyncGenerator<string> {
const input = messages.map((message) => ({
role: message.role,
content: message.text
}));
const stream = await client.responses.create({
model: env.OPENAI_MODEL,
instructions:
"You are a bounded Slack support assistant. Answer the support question, state uncertainty, never claim to perform an action, never request secrets, and recommend the explicit human-escalation button when evidence is incomplete.",
input,
stream: true,
max_output_tokens: 700
});
for await (const event of stream) {
if (event.type === "response.output_text.delta") yield event.delta;
if (event.type === "response.failed") throw new Error("openai_response_failed");
if (event.type === "response.incomplete") throw new Error("openai_response_incomplete");
}
}
OPENAI STREAM FIXTURE response.created response.output_text.delta: "Check the service status" response.output_text.delta: " and the latest error." response.completed assembled_chars=46 external_tools=0 secrets_in_input=0
Checkpoint: Pass typed provider-state fixtures
npm testContinue whenDelta fixtures assemble exact text, completed ends normally, failed/incomplete/timeout/refusal states do not appear completed, maximum context and output are enforced, and the request contains no Slack token, channel API client or external tool.
Stop whenStop if the model can call Slack or escalation, raw retained content crosses a tenant/thread boundary, an unknown event type becomes success, or provider retries are unbounded.
If this step fails
A streamed answer stops mid-sentence or leaves an unfinished Slack message.
Likely causeOpenAI returned incomplete/failed, the process restarted, chat.appendStream was rate-limited, a network error interrupted the loop, or chat.stopStream failed after content was appended.
Inspect typed OpenAI terminal event, response ID, Slack stream timestamp, last append time, retry_after and sanitized error class.Compare the stored assistant row and stream timestamp with the visible Slack message; do not reconstruct missing private text from logs.
ResolutionStop the stream with a clear incomplete message when possible, mark the request failed, offer one explicit retry and the human path, and reconcile orphaned stream timestamps. Do not silently continue with a second public message that looks like the same answer.
The assistant repeats a secret or personal detail in its answer or logs.
Likely causeA user pasted sensitive data, mention/user identifiers were retained unnecessarily, raw thread text reached ordinary logging, or model output echoed content before application minimization.
Inspect content-free request IDs, redaction counters, message lengths and access logs; restrict any raw database review to the privacy incident workflow.Search approved log fields for synthetic token patterns and verify Bolt/client logging is configured not to include payloads.
ResolutionRestrict and delete affected copies, rotate exposed credentials, notify the privacy owner, improve input warnings and redaction, and add a synthetic regression. Do not copy the message into an incident ticket or chat.
Security notes
- Redaction is data minimization, not proof that a message is non-sensitive; define retention and user notices accordingly.
- Never send signing secrets, OAuth tokens, client secrets, state secrets, installation ciphertext, database credentials or admin exports to OpenAI.
Alternatives
- Use a non-streaming provider response when the simpler atomic Slack post materially improves recovery and the latency tradeoff is acceptable.
Stop conditions
- Provider data controls or workspace policy do not allow sending the approved redacted context.
config
Implement slash commands, events, native streaming, and Retry-After
Save src/app.ts. Bolt verifies Slack requests and owns OAuth routes. The slash command calls ack first with an ephemeral progress message, claims a durable command key, validates the prompt/channel, consumes the bounded provider stream privately and replaces the ephemeral response with one final answer; it does not invent a thread timestamp. The app_mention listener relies on Bolt's fast Events API acknowledgement, claims body.event_id and uses event.thread_ts or event.ts. That response path loads only the composite thread context, starts chat.startStream with Slack's required channel, thread and recipient identifiers, batches deltas, appends with chat.appendStream, stops with chat.stopStream and adds the human button. Web API calls honor bounded Retry-After rather than blind retries.
Why this step matters
Slack requires a prompt acknowledgement while model generation may take seconds. A durable claim before side effects, native Slack streaming, bounded batching and method-aware Retry-After handling produce a responsive experience without duplicate provider or message work.
What to understand
Call ack before database or provider work for commands and actions. Bolt acknowledges Events API deliveries under its normal HTTP receiver behavior; keep listeners free of synchronous startup work and monitor acknowledgement latency separately from completion.
For an app mention, thread_ts or event.ts plus the verified recipient team/user are authoritative and satisfy chat.startStream. A slash command has no source-message thread timestamp, so this implementation returns a one-off final ephemeral response and tells the user to mention the app in an approved thread for persistent native streaming and escalation.
chat.startStream, chat.appendStream and chat.stopStream are distinct writes. Record the returned ts and reconcile a partial or ambiguous outcome before attempting a replacement. A stream that shows partial content cannot be made idempotent by posting another message.
System changes
- Registers live OAuth, slash-command, Events API and interactive Bolt routes; slash commands create one-off ephemeral results, while verified app mentions create native Slack stream messages in approved threads.
- Stores redacted user/assistant context and durable delivery claims, and calls OpenAI for each accepted delivery.
Syntax explained
await ack() first- Satisfies Slack's command/action acknowledgement deadline before slow work.
body.event_id- Provides Slack's durable Events API retry identity.
chat.startStream/appendStream/stopStream- Uses Slack's native assistant streaming lifecycle rather than repeatedly editing an ordinary message.
Retry-After- Supplies the method/workspace-specific delay after a Web API rate limit.
src/app.tsimport { App, LogLevel } from "@slack/bolt";
import type { WebClient } from "@slack/web-api";
import { createHash } from "node:crypto";
import { environmentSchema, allowedChannelSet, boundedPrompt, redactSlackText, safeSlackMarkdown, slackApiCall } from "./policy.js";
import {
appendMessage,
cancelEscalation,
claimDelivery,
confirmEscalation,
createEscalationDraft,
installationStore,
loadThread,
markEscalationDelivered
} from "./state.js";
import { streamAssistant } from "./assistant.js";
const env = environmentSchema.parse(process.env);
const allowedChannels = allowedChannelSet(env.ALLOWED_CHANNEL_IDS);
const app = new App({
signingSecret: env.SLACK_SIGNING_SECRET,
clientId: env.SLACK_CLIENT_ID,
clientSecret: env.SLACK_CLIENT_SECRET,
stateSecret: env.SLACK_STATE_SECRET,
scopes: env.SLACK_SCOPES.split(",").map((scope) => scope.trim()),
installationStore,
tokenRotationEnabled: true,
logLevel: LogLevel.INFO
});
function deliveryKey(parts: string[]): string {
return createHash("sha256").update(parts.join("\u0000")).digest("hex");
}
async function postEphemeral(client: WebClient, channel: string, user: string, text: string): Promise<void> {
await slackApiCall(() => client.chat.postEphemeral({ channel, user, text }));
}
async function streamToSlack(input: {
client: WebClient;
teamId: string;
channelId: string;
threadTs: string;
userId: string;
prompt: string;
}): Promise<void> {
if (!allowedChannels.has(input.channelId)) {
await postEphemeral(input.client, input.channelId, input.userId, "This assistant is not enabled in this channel.");
return;
}
const prior = await loadThread(
input.teamId,
input.channelId,
input.threadTs,
env.MAX_CONTEXT_MESSAGES - 1
);
const context = [...prior, { role: "user" as const, text: input.prompt }];
const started = await slackApiCall(() =>
input.client.apiCall("chat.startStream", {
channel: input.channelId,
thread_ts: input.threadTs,
recipient_team_id: input.teamId,
recipient_user_id: input.userId
})
) as { ts?: string };
if (!started.ts) throw new Error("slack_stream_missing_ts");
await appendMessage({
teamId: input.teamId,
channelId: input.channelId,
threadTs: input.threadTs,
userId: input.userId,
role: "user",
text: input.prompt
});
let pending = "";
let complete = "";
let lastFlush = Date.now();
try {
for await (const delta of streamAssistant(context)) {
const safeDelta = safeSlackMarkdown(delta);
pending += safeDelta;
complete += safeDelta;
if (pending.length >= 600 || Date.now() - lastFlush >= env.STREAM_FLUSH_MS) {
const chunk = pending;
pending = "";
lastFlush = Date.now();
await slackApiCall(() => input.client.apiCall("chat.appendStream", {
channel: input.channelId,
ts: started.ts,
markdown_text: chunk
}));
}
}
if (pending) {
await slackApiCall(() => input.client.apiCall("chat.appendStream", {
channel: input.channelId,
ts: started.ts,
markdown_text: pending
}));
}
await slackApiCall(() => input.client.apiCall("chat.stopStream", {
channel: input.channelId,
ts: started.ts,
blocks: [
{
type: "actions",
elements: [{
type: "button",
action_id: "request_human_escalation",
text: { type: "plain_text", text: "Ask a human" },
value: deliveryKey([input.teamId, input.channelId, input.threadTs, input.userId, complete.slice(0, 256)])
}]
}
]
}));
await appendMessage({
teamId: input.teamId,
channelId: input.channelId,
threadTs: input.threadTs,
role: "assistant",
text: complete,
slackTs: started.ts
});
console.log(JSON.stringify({
event: "assistant_completed",
teamId: input.teamId,
channelId: input.channelId,
threadTs: input.threadTs,
slackTs: started.ts,
characters: complete.length
}));
} catch (error) {
await input.client.apiCall("chat.stopStream", {
channel: input.channelId,
ts: started.ts,
markdown_text: "The assistant could not complete this response. Please retry once or ask a human."
}).catch(() => undefined);
throw error;
}
}
app.command("/support", async ({ ack, command, body, client, logger, respond }) => {
await ack({ response_type: "ephemeral", text: "Working on a bounded response…" });
try {
const deliveryId = deliveryKey([body.team_id, command.channel_id, command.user_id, command.trigger_id]);
if (!await claimDelivery(deliveryId, "command")) return;
const prompt = boundedPrompt(command.text, env.MAX_PROMPT_CHARS);
if (!allowedChannels.has(command.channel_id)) {
await respond({ response_type: "ephemeral", replace_original: true, text: "This assistant is not enabled in this channel." });
return;
}
let complete = "";
for await (const delta of streamAssistant([{ role: "user", text: prompt }])) {
complete += safeSlackMarkdown(delta);
}
await respond({
response_type: "ephemeral",
replace_original: true,
text: complete || "The assistant could not produce a complete response.",
blocks: [{
type: "section",
text: { type: "mrkdwn", text: complete || "The assistant could not produce a complete response." }
}, {
type: "context",
elements: [{
type: "mrkdwn",
text: "For a persistent streamed conversation and human escalation, mention the app in an approved support thread."
}]
}]
});
} catch (error) {
logger.error({ code: error instanceof Error ? redactSlackText(error.message) : "unknown" });
await respond({ response_type: "ephemeral", replace_original: true, text: "The assistant could not process that request safely." });
}
});
app.event("app_mention", async ({ event, body, client, logger }) => {
try {
if (!await claimDelivery(body.event_id, "event")) return;
if (!body.team_id || !event.user) throw new Error("slack_event_identity_missing");
const prompt = boundedPrompt(event.text, env.MAX_PROMPT_CHARS);
await streamToSlack({
client,
teamId: body.team_id,
channelId: event.channel,
threadTs: event.thread_ts ?? event.ts,
userId: event.user,
prompt
});
} catch (error) {
logger.error({ eventId: body.event_id, code: error instanceof Error ? redactSlackText(error.message) : "unknown" });
}
});
app.action("request_human_escalation", async ({ ack, body, action, client }) => {
await ack();
if (body.type !== "block_actions" || action.type !== "button") return;
if (!action.value) return;
const teamId = body.team?.id;
const channelId = body.channel?.id;
const userId = body.user.id;
const threadTs = body.message?.thread_ts ?? body.message?.ts;
if (!teamId || !channelId || !threadTs || !allowedChannels.has(channelId)) return;
const operationKey = deliveryKey([teamId, channelId, threadTs, userId, action.value]);
const summary = "Human review requested for Slack thread " + channelId + ":" + threadTs;
const id = await createEscalationDraft({
operationKey,
teamId,
channelId,
threadTs,
requesterId: userId,
summary
});
await postEphemeral(
client,
channelId,
userId,
"Confirm sending a redacted thread reference to the human-support channel: " +
"https://bot.example.com/confirm/" + id
);
await slackApiCall(() => client.chat.postEphemeral({
channel: channelId,
user: userId,
text: "Human escalation is still a draft.",
blocks: [{
type: "actions",
elements: [
{ type: "button", action_id: "confirm_human_escalation", style: "primary", text: { type: "plain_text", text: "Confirm escalation" }, value: id },
{ type: "button", action_id: "cancel_human_escalation", text: { type: "plain_text", text: "Cancel" }, value: id }
]
}]
}));
});
app.action("confirm_human_escalation", async ({ ack, body, action, client }) => {
await ack();
if (body.type !== "block_actions" || action.type !== "button") return;
if (!action.value) return;
const escalation = await confirmEscalation(action.value, body.user.id);
if (escalation.status === "delivered") return;
const result = await slackApiCall(() => client.chat.postMessage({
channel: env.ESCALATION_CHANNEL_ID,
text: "Confirmed human review request " + escalation.id,
unfurl_links: false,
unfurl_media: false,
blocks: [{
type: "section",
text: {
type: "mrkdwn",
text: "*Confirmed support escalation*\nRequest: `" + escalation.id + "`\nSource: <#" +
escalation.channelId + "> thread `" + escalation.threadTs + "`\nRequester: <@" +
escalation.requesterId + ">\nSummary: " + escalation.summary
}
}]
}));
if (!result.ts) throw new Error("escalation_delivery_missing_ts");
await markEscalationDelivered(escalation.id, result.ts);
});
app.action("cancel_human_escalation", async ({ ack, body, action, client }) => {
await ack();
if (body.type !== "block_actions" || action.type !== "button") return;
if (!action.value) return;
const cancelled = await cancelEscalation(action.value, body.user.id);
if (cancelled && body.channel?.id) {
await postEphemeral(client, body.channel.id, body.user.id, "Human escalation cancelled.");
}
});
await app.start(env.PORT);
console.log(JSON.stringify({ event: "slack_app_started", port: env.PORT, origin: env.PUBLIC_ORIGIN }));
EVENT TRACE ack_ms=21 kind=app_mention delivery_claimed=true team=T012DEVELOP channel=C012SUPPORT thread=1743412345.004200 recipient=U012USER chat.startStream ts=1743412346.001100 append_calls=4 characters=812 chat.stopStream ok=true openai_terminal=completed stored_context_messages=2 SLASH TRACE ack_ms=18 response=ephemeral_final stream_message_created=false
Checkpoint: Exercise acknowledgement, dedupe, stream, and rate-limit behavior
npm test && npm startContinue whenSigned slash commands acknowledge within three seconds and replace only their ephemeral response after provider work; duplicate event_id or trigger keys create no second result; unauthorized channels create no public answer; app-mention stream chunks include required recipient/thread identifiers, respect flush policy and Retry-After, and end visibly on failure.
Stop whenStop if acknowledgement waits for OpenAI, a retry creates a duplicate response, the returned Slack ts is not saved, appends ignore Retry-After, a stream remains silently open, or a channel outside the allowlist receives content.
If this step fails
Slack shows operation_timeout for a slash command.
Likely causeThe handler performed database, OpenAI or Slack Web API work before calling ack, or the process/proxy delayed the response past Slack's three-second requirement.
Measure request arrival to acknowledgement and separate it from background generation latency.Inspect whether database connection setup, dependency imports, logging or synchronous work occurs before the first await ack.
ResolutionMove acknowledgement to the first validated handler action, return a short ephemeral status, and continue generation asynchronously. Warm required resources at process start; do not increase the user-visible timeout or retry the command blindly.
The same mention or command produces duplicate replies.
Likely causeSlack retried an unacknowledged Events API delivery, the application did not persist event_id or the command delivery key, or two replicas claimed the same delivery without a database uniqueness constraint.
Compare Slack event_id, retry headers, trigger ID, team/channel/user identifiers and the slack_deliveries row.Confirm INSERT ... ON CONFLICT DO NOTHING occurs before OpenAI or message creation and that all replicas share the same database.
ResolutionKeep the first durable claim, suppress later deliveries, reconcile any already-created streams by Slack message timestamp, and add the exact retry case to tests. Do not deduplicate by message text.
A streamed answer stops mid-sentence or leaves an unfinished Slack message.
Likely causeOpenAI returned incomplete/failed, the process restarted, chat.appendStream was rate-limited, a network error interrupted the loop, or chat.stopStream failed after content was appended.
Inspect typed OpenAI terminal event, response ID, Slack stream timestamp, last append time, retry_after and sanitized error class.Compare the stored assistant row and stream timestamp with the visible Slack message; do not reconstruct missing private text from logs.
ResolutionStop the stream with a clear incomplete message when possible, mark the request failed, offer one explicit retry and the human path, and reconcile orphaned stream timestamps. Do not silently continue with a second public message that looks like the same answer.
Slack returns ratelimited while appending or stopping a stream.
Likely causeThe application flushed very small deltas, several bots share workspace limits, traffic spiked, or the retry loop ignored Slack's Retry-After value and compounded the limit.
Inspect method, HTTP status, Retry-After, workspace/team, stream timestamp, append size and attempt count.Verify STREAM_FLUSH_MS and chunk threshold under a synthetic load that does not send production messages.
ResolutionPause only the affected Web API call for the bounded Retry-After interval, aggregate larger chunks, cap attempts and surface an incomplete response if the deadline is exceeded. Do not retry without delay or create a replacement stream.
Security notes
- Never echo a Slack Web API error object to users; sanitize it because it may include request metadata.
- Pass unfurl_links and unfurl_media false on escalation notices so untrusted text cannot trigger link previews.
Alternatives
- Post one final message after an ephemeral progress acknowledgement if native stream methods are unavailable in the approved Slack plan or SDK.
Stop conditions
- Acknowledgement p95 approaches Slack's three-second deadline.
- The application cannot reconcile a partial stream or shared workspace rate limit.
decision
Gate human escalation with a durable draft and verified confirmation
Keep the model outside escalation. After a completed or insufficient answer, the application-authored Ask a human button creates one draft keyed by verified team, channel, thread, requester and action value. It responds privately with a second Confirm escalation button. Confirmation arrives in another Slack-signed action, requires the same verified requester, locks the database row, changes draft to confirmed, posts one redacted reference to the fixed escalation channel, records the destination timestamp and marks delivered. Cancellation must change draft to cancelled; ambiguous post outcomes require reconciliation by escalation ID before retry.
Why this step matters
Escalation is an external communication that can disclose workspace context and create a human obligation. Two explicit application states, requester ownership, a transaction and destination timestamp make consent and delivery auditable and prevent model text or a single accidental click from sending the message.
What to understand
The escalation summary should contain a redacted thread reference and minimal user-supplied context, not the entire conversation. Human responders can access the source thread under Slack's normal authorization.
Slack signs the action transport, but the application still checks that the confirmer owns the draft and that the source channel remains approved. Action.value is an opaque identifier, not an authorization decision.
chat.postMessage can fail after Slack accepted it. Before retrying an ambiguous outcome, search or inspect the fixed destination for the unique escalation ID, then record the authoritative ts. Blind retry creates duplicate human work.
System changes
- Writes escalation draft/confirmation/delivery state to PostgreSQL.
- Creates exactly one message in the fixed human-support channel only after the verified requester confirms.
ESCALATION STATE TRACE id=53f3ba7e-a4e2-4fa7-9f12-fc3c60e9f96d operation_key=8c31d2… requester=U012USER state=draft external_posts=0 confirm_user=U012USER signature=valid state=confirmed post channel=C056HUMANS ts=1743412399.004300 state=delivered external_posts=1
Checkpoint: Approve the human escalation contract
Continue whenUnconfirmed drafts create no external message; another user cannot confirm; cancellation is durable; confirmation posts only minimal redacted reference to the fixed channel; duplicate actions remain one delivery; and ambiguous outcomes enter reconciliation rather than automatic retry.
Stop whenStop if model output can create/confirm escalation, the source or destination channel comes from untrusted text, summary includes raw conversation/secrets, ownership is not checked, or a duplicate/ambiguous post cannot be reconciled.
If this step fails
A human escalation is sent twice or sent without the requester's confirmation.
Likely causeThe action handler trusted a manipulated value, did not check requester ownership and draft status transactionally, retried chat.postMessage after an ambiguous outcome, or marked delivery after posting without reconciliation.
Compare escalation ID, operation_key, requester, state history and destination Slack timestamp without exposing summary text.Verify the confirm action arrives in a Slack-signed body and the same verified user owns the draft.
ResolutionStop escalation delivery, reconcile the destination channel by escalation ID, preserve a single delivered timestamp, cancel unauthorized drafts and add idempotent delivery/reconciliation before retrying. Never let the model create or confirm the action.
Security notes
- Do not mention private-channel content in a destination whose members lack access to the source thread.
- Treat confirmed-but-undelivered escalations as an operational queue with alerts and a human service-level objective.
Alternatives
- Open an authenticated support portal deep link without sending Slack content when workspace privacy policy prohibits escalation messages.
- Require a designated on-duty approver, rather than the requester, to confirm escalations for regulated or high-impact workflows.
Stop conditions
- The destination membership or retention policy is incompatible with the source channel.
- The team cannot staff and monitor the confirmed escalation queue.
config
Test Slack signatures, isolation, retries, streaming, and actions
Save the shown policy tests, then add synthetic Bolt receiver fixtures for OAuth state mismatch, token rotation, valid/altered/stale signatures, URL-encoded slash bodies, Events API retries, command acknowledgement timing, unauthorized channels, two teams with identical channel IDs, two threads in one channel, OpenAI incomplete/failure, Slack start/append/stop rate limits, process restart mid-stream, draft ownership, duplicate confirmation, ambiguous escalation post, export and teardown. Use fake clients and a disposable PostgreSQL schema; never send messages to a real workspace in CI.
Why this step matters
The difficult Slack failures are retries, cross-boundary state, partial public messages and ambiguous external writes. Deterministic fake deliveries and database concurrency tests expose them safely and prove acknowledgement occurs before the intentionally slow dependencies.
What to understand
Generate signatures over the exact synthetic raw bytes and verify the same bytes reach Bolt. Include form encoding, JSON whitespace and Unicode cases because parsing then reserializing changes the signature base.
Run concurrent claims and confirmations against PostgreSQL, not mocks alone. Database uniqueness and row locks are the production idempotency mechanism.
A fake Slack client should return realistic method-specific ts, Retry-After, 429 and accepted-then-timeout states. Assert method, channel, thread and block payload without retaining real messages.
System changes
- Creates local/disposable synthetic test state only and performs no real Slack or OpenAI network calls.
Syntax explained
raw-body HMAC fixture- Proves the exact signed bytes and freshness window.
concurrent database fixture- Proves uniqueness and row locks across replica-like callers.
fake WebClient- Exercises Slack method payloads, ts identity and Retry-After without production messages.
test/policy.test.tsimport test from "node:test";
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { allowedChannelSet, boundedPrompt, redactSlackText, safeSlackMarkdown, verifySlackSignature } from "../src/policy.js";
test("verifies Slack v0 signatures over the exact raw body and rejects stale requests", () => {
const secret = "test-signing-secret-with-enough-entropy";
const timestamp = "1700000000";
const body = "command=%2Fsupport&text=hello";
const signature = "v0=" + createHmac("sha256", secret).update("v0:" + timestamp + ":" + body).digest("hex");
assert.equal(verifySlackSignature(secret, timestamp, body, signature, 1700000001), true);
assert.equal(verifySlackSignature(secret, timestamp, body + "x", signature, 1700000001), false);
assert.equal(verifySlackSignature(secret, timestamp, body, signature, 1700000601), false);
});
test("redacts Slack/OpenAI tokens, email and mention identifiers", () => {
const output = redactSlackText("xoxb-123-secret sk-example_123456789 user@example.com <@U123ABC>");
assert.equal(output.includes("xoxb-"), false);
assert.equal(output.includes("user@example.com"), false);
assert.equal(output.includes("U123ABC"), false);
});
test("allows only reviewed channel IDs and bounded prompts", () => {
const channels = allowedChannelSet("C012ABC,invalid,C034DEF");
assert.deepEqual([...channels], ["C012ABC", "C034DEF"]);
assert.equal(boundedPrompt("<@U123ABC> explain the alert", 100), "explain the alert");
assert.throws(() => boundedPrompt("x".repeat(101), 100), /prompt_out_of_bounds/);
});
test("neutralizes Slack mention and link control syntax in model output", () => {
assert.equal(
safeSlackMarkdown("<!channel> see <https://evil.example|this> & <@U123ABC>"),
"<!channel> see <https://evil.example|this> & <@U123ABC>"
);
});
TAP version 13 ok 1 - verifies Slack v0 signatures and rejects stale requests ok 2 - redacts Slack/OpenAI tokens, email and mention identifiers ok 3 - allows only reviewed channel IDs and bounded prompts ok 4 - claims one event across concurrent replicas ok 5 - isolates team/channel/thread context ok 6 - confirms one escalation after requester approval 1..6 # pass 6 # fail 0
Checkpoint: Pass the production failure matrix
npm run check && npm testContinue whenAuthentication, acknowledgement, dedupe, tenant/thread isolation, redaction, typed provider states, native stream recovery, Retry-After, escalation confirmation/reconciliation, export and destructive confirmation all pass deterministically.
Stop whenStop if any test needs a real token/workspace, an altered or stale request is accepted, timing depends on test order, a duplicate produces two writes, or raw payload content appears in snapshots.
Security notes
- Use synthetic xoxb/sk-like strings that are structurally realistic but not valid credentials.
- Bind fixture receivers and databases to isolated test networks and delete them after the run.
Alternatives
- Use a dedicated Slack development workspace only for a final canary smoke after local receiver and client tests pass.
Stop conditions
- A critical signed-request, isolation or escalation property is covered only by manual testing.
config
Deploy a hardened HTTPS canary and install one approved workspace
Save the systemd unit, install the signed bundle as an immutable release under /opt/slack-bot, inject secrets through /etc/slack-bot/slack-bot.env, run as the non-root slack-bot account, place PostgreSQL and OpenAI behind required outbound controls, and expose only the Bolt routes through the trusted TLS proxy. Apply body/time limits without transforming the raw Slack body. Install the production Slack app in one approved workspace and two canary channels, then verify OAuth, token rotation, command, mention, stream, rate limit, escalation, uninstall and rollback behavior before adding workspaces.
Why this step matters
A single-workspace canary bounds mistakes in scopes, signature proxying, OAuth keys, context isolation, native stream methods and human-support routing. A hardened non-root service and narrow network routes reduce the impact of token or application compromise.
What to understand
Do not enable production OAuth installation before restore, uninstall and token-rotation drills pass. Keep development and production Slack apps, databases, provider projects and secrets separate.
The proxy must preserve raw body bytes, reject oversized or slow requests, strip untrusted forwarding headers and avoid request-body logs. Health checks use a separate non-Slack route that cannot trigger Bolt listeners.
The systemd service has no filesystem write path; all durable state is in PostgreSQL. If the runtime needs temporary SDK files, grant a narrow state path rather than weakening ProtectSystem.
System changes
- Starts a persistent non-root Bolt service, exposes signed Slack/OAuth routes through TLS and creates one approved production-workspace installation.
- Canary commands and mentions create OpenAI requests and Slack stream messages; confirmed canary escalation creates one message in the fixed human channel.
Syntax explained
ProtectSystem=strict- Makes the deployed host filesystem read-only for the Slack service.
CapabilityBoundingSet=- Removes Linux capabilities that the HTTP application does not need.
one-workspace canary- Bounds OAuth, state and messaging impact before distribution expands.
/etc/systemd/system/slack-bot.service[Unit]
Description=Production Slack support assistant
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=slack-bot
Group=slack-bot
WorkingDirectory=/opt/slack-bot/current
EnvironmentFile=/etc/slack-bot/slack-bot.env
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target$ systemctl show slack-bot -p ActiveState -p SubState -p User ActiveState=active SubState=running User=slack-bot Slack request URL: verified OAuth install: T012CANARY stored encrypted /signature p95=18ms /ack p95=34ms Allowed channels=2 unauthorized public replies=0
Checkpoint: Accept the workspace canary
Continue whenOne approved workspace installs through OAuth, request signatures and ack latency remain healthy, only allowlisted channels receive answers, context is isolated, native streams close, Retry-After works, confirmed escalation is unique, token rotation updates ciphertext, and uninstall/rollback are proven.
Stop whenStop on public request-body logging, raw-body transformation, unexpected scope, root service, plaintext token, cross-thread context, duplicate response, unfinished stream, escalation without confirmation, unstaffed queue or rollback/uninstall failure.
If this step fails
Slack rejects the Events API or interactive request URL.
Likely causeThe public endpoint is not valid HTTPS, the certificate chain is incomplete, the route is wrong, the server is unavailable, or the app did not return the expected challenge or acknowledgement quickly enough.
Check the exact request URL, TLS chain, proxy route and application health without logging request bodies or signing secrets.Inspect Slack delivery status, request timestamp, event ID and sanitized response latency; confirm the listener acknowledges before provider work.
ResolutionRestore the known-good HTTPS route and fast acknowledgement path, then use Slack's app configuration retry. Keep AI generation outside the acknowledgement deadline and do not disable signature verification to make the check pass.
Slack shows operation_timeout for a slash command.
Likely causeThe handler performed database, OpenAI or Slack Web API work before calling ack, or the process/proxy delayed the response past Slack's three-second requirement.
Measure request arrival to acknowledgement and separate it from background generation latency.Inspect whether database connection setup, dependency imports, logging or synchronous work occurs before the first await ack.
ResolutionMove acknowledgement to the first validated handler action, return a short ephemeral status, and continue generation asynchronously. Warm required resources at process start; do not increase the user-visible timeout or retry the command blindly.
Security notes
- Restrict outbound network to Slack, OpenAI and PostgreSQL endpoints required by the reviewed application.
- Keep a kill switch that disables generation while signed endpoints still acknowledge with a safe maintenance message.
Alternatives
- Run the immutable non-root service in an approved container platform with equivalent raw-body proxying, secret, network, filesystem and rollback controls.
Stop conditions
- The hosting layer cannot preserve Slack's exact raw body or meet the acknowledgement SLO.
- Canary workspace administrators cannot revoke the app promptly.
instruction
Observe acknowledgements, retries, streams, cost, and human work
Publish content-free metrics and alerts for signed request accept/reject and age, acknowledgement p50/p95/max, duplicate claims, OAuth install/rotation/delete, accepted/blocked channels, database latency, context message count, OpenAI completed/incomplete/failed and token usage, Slack start/append/stop status, Retry-After by method, unfinished stream age, response completion latency, escalation draft/confirmed/delivered/cancelled/failed counts, confirmed queue age, export/teardown access, retention deletions and release revision. Never use raw Slack or model text as a metric label or ordinary log field.
Why this step matters
Slack can appear responsive while acknowledgements approach timeout, retries create hidden duplicate cost, native streams remain unfinished, one workspace dominates rate limits, context retention grows or confirmed human requests wait. Typed content-free metrics make those operational failures visible without copying conversations.
What to understand
Segment by immutable release and pseudonymous team identifier. Protect even pseudonymous workspace/channel/thread identifiers as operational metadata and apply retention.
Alert before Slack's acknowledgement deadline, on any signature anomaly spike, duplicate side effect, orphaned stream, unresolved Retry-After, cross-boundary query test failure or confirmed escalation older than the service objective.
Model usage and cost belong to the accepted delivery/release, not raw prompt text. Review cost per completed supported response and disable generation during unexplained growth.
System changes
- Adds dashboards, alerts, retention jobs and audited operational access; it does not modify Slack content or model behavior by itself.
SLACK CANARY 60m signed=903 rejected_signature=0 stale=2 ack_p95=41ms duplicates=17 commands=288 mentions=598 blocked_channel=17 openai_completed=842 incomplete=8 failed=9 input_tokens=341802 output_tokens=117994 streams_started=859 stopped=850 orphaned=0 ratelimited_append=6 escalations draft=4 confirmed=3 delivered=3 oldest_confirmed=0m active_release=slack-bot-2026-07-29.1
Checkpoint: Accept operational and support-service gates
Continue whenAcknowledgement, signature, duplicate, context, provider, native stream, rate-limit, token/cost, retention and escalation metrics stay within frozen thresholds for the canary window, and each alert is tested with synthetic events.
Stop whenStop expansion if metrics include raw content, acknowledgement approaches three seconds, orphaned/duplicate streams occur, costs are unexplained, token rotation fails, confirmed escalation misses SLO, or any metric cannot be attributed to one release.
Security notes
- Do not log Slack body, Block Kit payload, decrypted installation, OpenAI input/output or escalation summary.
- Restrict dashboards that reveal workspace/channel/user identifiers and audit export access.
Alternatives
- Use local aggregate metrics without workspace labels when policy forbids exporting identifiers.
Stop conditions
- The team cannot detect and disable a token, privacy, duplicate, cost or escalation incident promptly.
decision
Export evidence, rehearse rollback, and teardown installations safely
Save src/admin.ts and keep it outside the public process. An authorized operator can export metadata, redacted context and escalation lifecycle to a mode-0600 file, then encrypt and inventory it under retention. Database backups remain the recovery artifact and must restore independently. Rollback disables new generation, preserves acknowledgements and confirmed human work, restores the previous signed bundle/database-compatible schema and model revision, and reconciles streams/escalations. Final teardown disables ingress, exports required evidence, resolves confirmed escalations, uninstalls/revokes Slack installations, verifies no active tokens, deletes retained messages/deliveries/escalations/installations only after explicit DELETE-SLACK-BOT-STATE, removes backups/secrets under policy and verifies provider-state deletion where applicable.
Why this step matters
Slack uninstall and application deletion cross OAuth credentials, durable retry claims, conversation retention, open streams, confirmed human obligations, backups and provider data. A separate authenticated admin command, explicit destructive phrase, export and reconciliation prevent an HTTP request or model output from erasing evidence or abandoning users.
What to understand
The export intentionally excludes decrypted installation tokens. A database backup contains ciphertext and still requires the encryption key for recovery; protect and rotate those artifacts independently.
Rollback does not undo already-visible Slack messages. Record their timestamps, end unfinished streams with a clear incident notice when possible, and preserve destination escalation IDs for reconciliation.
Uninstall/revoke in Slack before deleting local installation ciphertext when the token state is uncertain. Verify active installations through the approved Slack administration path, then remove database and backup copies according to retention.
System changes
- Creates a protected export and may restore application/database state during rollback.
- Confirmed teardown revokes Slack installations, deletes encrypted tokens, redacted context, delivery claims and escalation state, removes backups/secrets and stops the public service.
Syntax explained
export <path>- Writes a mode-0600, content-minimized operational export without decrypting OAuth installations.
teardown DELETE-SLACK-BOT-STATE- Requires an explicit destructive phrase before truncating retained application state.
src/admin.tsimport { writeFile } from "node:fs/promises";
import { exportState, teardownDatabase } from "./state.js";
const [command, argument] = process.argv.slice(2);
if (command === "export") {
const output = argument ?? "slack-bot-export.json";
await writeFile(output, JSON.stringify(await exportState(), null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
console.log(JSON.stringify({ status: "exported", output }));
} else if (command === "teardown") {
await teardownDatabase(argument ?? "");
console.log(JSON.stringify({ status: "database_state_deleted" }));
} else {
throw new Error("Usage: npm run admin -- export <path> | teardown DELETE-SLACK-BOT-STATE");
}
$ npm run admin -- export /secure/slack-bot-2026-07-29.json
{"status":"exported","output":"/secure/slack-bot-2026-07-29.json"}
mode=0600 installations_metadata=1 messages_redacted=842 escalations=7
ROLLBACK DRILL previous_release=2026-07-18.1 ack_p95=39ms streams_reconciled=1 confirmed_escalations=0
$ npm run admin -- teardown WRONG
Error: destructive_confirmation_requiredCheckpoint: Approve rollback and destructive teardown
Continue whenThe previous signed release starts against a compatible restored database, signed events acknowledge safely, no cross-thread context appears, streams and confirmed escalations reconcile, export is complete and protected, every Slack installation/token and retained copy is inventoried, and teardown requires a separately authenticated explicit phrase.
Stop whenStop if confirmed human work is unresolved, a stream outcome is ambiguous, installation inventory differs, backup restore failed, export access is uncontrolled, schema is incompatible, or any active token/retained copy cannot be verified.
If this step fails
Removing the Slack app leaves tokens, memory or pending escalations behind.
Likely causeUninstall events or the deletion runbook did not enumerate encrypted installations, workspace messages, delivery claims, exports, backups, provider data and confirmed human obligations.
Export metadata and count records by team/status under an authorized operator session; do not decrypt tokens into the export.Compare Slack installation inventory, database rows, backups, secret references and pending/delivered escalation states.
ResolutionDisable ingress, resolve confirmed human work, export the approved audit subset, revoke or uninstall the Slack app, delete encrypted installation and conversation state under retention, remove backups, rotate secrets and verify zero active installations before closing teardown.
Security notes
- Run export and teardown from an audited operator environment with no public HTTP path to the command.
- Do not decrypt OAuth installation payloads into exports, shell history, tickets or screenshots.
Alternatives
- Disable generation and leave signed acknowledgements plus human support active while a non-destructive incident investigation completes.
- Reinstall one affected workspace instead of global teardown when tenant isolation and token inventory prove the incident is contained.
Stop conditions
- No current encrypted backup and successful isolated restore exist.
- Slack administrators cannot revoke installations or confirmed escalation work remains unowned.
Finish line
Verification checklist
npm run check && npm testStrict TypeScript, exact raw-body signatures, replay rejection, redaction, channel parsing, OAuth encryption/rotation, delivery uniqueness, composite thread isolation, typed provider events, rate limits and escalation ownership all pass.npm startOAuth installs one development workspace, /support and app mentions acknowledge promptly, duplicate events do not repeat, streams close in the correct thread, unauthorized channels stay quiet and confirmed escalation delivers once.systemctl status slack-bot --no-pagerThe non-root immutable service is healthy behind the raw-body-preserving TLS proxy; acknowledgement, citation/quality, stream, Retry-After, cost and escalation metrics remain within approved canary thresholds.npm run admin -- export /secure/slack-bot-drill.jsonA mode-0600 content-minimized export is created, the encrypted PostgreSQL backup restores in isolation, the previous bundle handles signed test deliveries and every open stream/escalation is reconciled.Recovery guidance
Common problems and safe checks
Slack rejects the Events API or interactive request URL.
Likely causeThe public endpoint is not valid HTTPS, the certificate chain is incomplete, the route is wrong, the server is unavailable, or the app did not return the expected challenge or acknowledgement quickly enough.
Check the exact request URL, TLS chain, proxy route and application health without logging request bodies or signing secrets.Inspect Slack delivery status, request timestamp, event ID and sanitized response latency; confirm the listener acknowledges before provider work.
ResolutionRestore the known-good HTTPS route and fast acknowledgement path, then use Slack's app configuration retry. Keep AI generation outside the acknowledgement deadline and do not disable signature verification to make the check pass.
Valid-looking Slack requests fail signature verification.
Likely causeThe application verified parsed form fields instead of the exact raw body, used the wrong signing secret, allowed a proxy to rewrite the body, compared the wrong version prefix, or the host clock differs by more than the replay window.
Compare the request timestamp, body byte length, signature version, trusted proxy behavior and service clock; never print the raw signed body.Run the local known-signature fixture and confirm Bolt receives the untouched body on /slack/events.
ResolutionRestore the current signing secret from the secret manager, preserve the raw body through the proxy, synchronize the clock and redeploy the reviewed verifier. Rotate the secret if exposure is suspected; never accept unsigned or stale deliveries.
Slack shows operation_timeout for a slash command.
Likely causeThe handler performed database, OpenAI or Slack Web API work before calling ack, or the process/proxy delayed the response past Slack's three-second requirement.
Measure request arrival to acknowledgement and separate it from background generation latency.Inspect whether database connection setup, dependency imports, logging or synchronous work occurs before the first await ack.
ResolutionMove acknowledgement to the first validated handler action, return a short ephemeral status, and continue generation asynchronously. Warm required resources at process start; do not increase the user-visible timeout or retry the command blindly.
The same mention or command produces duplicate replies.
Likely causeSlack retried an unacknowledged Events API delivery, the application did not persist event_id or the command delivery key, or two replicas claimed the same delivery without a database uniqueness constraint.
Compare Slack event_id, retry headers, trigger ID, team/channel/user identifiers and the slack_deliveries row.Confirm INSERT ... ON CONFLICT DO NOTHING occurs before OpenAI or message creation and that all replicas share the same database.
ResolutionKeep the first durable claim, suppress later deliveries, reconcile any already-created streams by Slack message timestamp, and add the exact retry case to tests. Do not deduplicate by message text.
OAuth installation succeeds but the bot cannot fetch the installation later.
Likely causeEnterprise and team keys were derived inconsistently, installation data failed encryption/decryption, token rotation updated only memory, or the OAuth callback wrote to a different database.
Inspect only enterprise/team key, installed/updated timestamps, ciphertext presence and token-rotation callback status; never print decrypted installation JSON.Use a synthetic installation fixture to round-trip AES-GCM under the configured 32-byte key.
ResolutionRestore the matching encryption key and database, correct enterprise/team key derivation, reinstall the app if token state cannot be proven, and revoke orphaned installations. Do not fall back to a single hard-coded bot token.
The bot responds in a channel that was not approved.
Likely causeAllowed-channel parsing accepted malformed identifiers, the check occurred after posting, an interaction reused a channel from untrusted action value, or a workspace admin changed topology without updating policy.
Compare the verified Slack body channel ID with the parsed ALLOWED_CHANNEL_IDS set and the app installation team.Run an unauthorized-channel fixture and confirm only a private explanatory acknowledgement is attempted.
ResolutionStop generation before OpenAI, post no public answer, update the allowlist only through the reviewed deployment process and add the channel to the operations inventory. Never rely on channel names, which can change.
A streamed answer stops mid-sentence or leaves an unfinished Slack message.
Likely causeOpenAI returned incomplete/failed, the process restarted, chat.appendStream was rate-limited, a network error interrupted the loop, or chat.stopStream failed after content was appended.
Inspect typed OpenAI terminal event, response ID, Slack stream timestamp, last append time, retry_after and sanitized error class.Compare the stored assistant row and stream timestamp with the visible Slack message; do not reconstruct missing private text from logs.
ResolutionStop the stream with a clear incomplete message when possible, mark the request failed, offer one explicit retry and the human path, and reconcile orphaned stream timestamps. Do not silently continue with a second public message that looks like the same answer.
Slack returns ratelimited while appending or stopping a stream.
Likely causeThe application flushed very small deltas, several bots share workspace limits, traffic spiked, or the retry loop ignored Slack's Retry-After value and compounded the limit.
Inspect method, HTTP status, Retry-After, workspace/team, stream timestamp, append size and attempt count.Verify STREAM_FLUSH_MS and chunk threshold under a synthetic load that does not send production messages.
ResolutionPause only the affected Web API call for the bounded Retry-After interval, aggregate larger chunks, cap attempts and surface an incomplete response if the deadline is exceeded. Do not retry without delay or create a replacement stream.
Thread memory includes another channel, workspace or conversation.
Likely causeThe database query omitted team_id, channel_id or thread_ts, a slash-command pseudo-thread collided, or client-supplied identifiers were trusted instead of the verified Slack body.
Inspect the exact composite key and row counts with redacted text, then run two-workspace and two-thread isolation fixtures.Confirm slash commands stay one-off and ephemeral, while app mentions persist only the event's verified team/channel/thread key.
ResolutionDisable the bot, preserve access evidence, remove cross-boundary records under incident handling, fix the composite query and rerun tenant/thread isolation tests before restoring traffic. Treat exposed content as a privacy incident.
The assistant repeats a secret or personal detail in its answer or logs.
Likely causeA user pasted sensitive data, mention/user identifiers were retained unnecessarily, raw thread text reached ordinary logging, or model output echoed content before application minimization.
Inspect content-free request IDs, redaction counters, message lengths and access logs; restrict any raw database review to the privacy incident workflow.Search approved log fields for synthetic token patterns and verify Bolt/client logging is configured not to include payloads.
ResolutionRestrict and delete affected copies, rotate exposed credentials, notify the privacy owner, improve input warnings and redaction, and add a synthetic regression. Do not copy the message into an incident ticket or chat.
A human escalation is sent twice or sent without the requester's confirmation.
Likely causeThe action handler trusted a manipulated value, did not check requester ownership and draft status transactionally, retried chat.postMessage after an ambiguous outcome, or marked delivery after posting without reconciliation.
Compare escalation ID, operation_key, requester, state history and destination Slack timestamp without exposing summary text.Verify the confirm action arrives in a Slack-signed body and the same verified user owns the draft.
ResolutionStop escalation delivery, reconcile the destination channel by escalation ID, preserve a single delivered timestamp, cancel unauthorized drafts and add idempotent delivery/reconciliation before retrying. Never let the model create or confirm the action.
Removing the Slack app leaves tokens, memory or pending escalations behind.
Likely causeUninstall events or the deletion runbook did not enumerate encrypted installations, workspace messages, delivery claims, exports, backups, provider data and confirmed human obligations.
Export metadata and count records by team/status under an authorized operator session; do not decrypt tokens into the export.Compare Slack installation inventory, database rows, backups, secret references and pending/delivered escalation states.
ResolutionDisable ingress, resolve confirmed human work, export the approved audit subset, revoke or uninstall the Slack app, delete encrypted installation and conversation state under retention, remove backups, rotate secrets and verify zero active installations before closing teardown.
Reference
Frequently asked questions
Why must slash commands acknowledge before the answer is ready?
Slack expects an acknowledgement within about three seconds. It confirms receipt, not completion. The bot calls ack first with a short ephemeral message, then performs durable claim, context, OpenAI and Slack streaming work. Waiting for the model causes operation_timeout and Slack/user retries.
Does Bolt verify Slack signatures automatically?
Yes, when configured with the signing secret and given the exact raw request body. The guide also includes a standalone verifier for deterministic tests. The proxy must not parse and reserialize the body before Bolt; clock freshness and secret rotation still matter.
Why not use one bot token from an environment variable?
A hard-coded token does not model multi-workspace OAuth, installation ownership, token rotation or uninstall. Bolt's installation store fetches the correct encrypted installation for the verified workspace/enterprise identity and updates rotating tokens atomically.
Do slash commands require broad message history?
No. This design treats /support as one-off and ephemeral, so it does not retain slash-command history. Persistent context exists only for messages that explicitly mention the app and is keyed by verified team, channel and thread. The app requests commands, app_mentions:read and chat:write, not general public/private channel history.
Is repeatedly editing a normal Slack message the same as native streaming?
No. This guide uses Slack's chat.startStream, chat.appendStream and chat.stopStream methods and records the returned timestamp. Native stream lifecycle and rate limits still require batching, Retry-After handling and a visible incomplete terminal state.
Can the bot retry every Slack rate limit automatically?
Only the affected method may wait for the bounded Retry-After value and retry up to the reviewed attempt limit. A partial or ambiguous public write must be reconciled by timestamp/unique escalation ID before retry; blind replacement messages create duplicates.
Who confirms human escalation?
The verified requester creates a draft and must click a second confirmation action before the app posts a minimal reference to the fixed human channel. Higher-risk organizations can require an on-duty approver instead. The model cannot create or confirm it.
Can rollback remove a message already visible in Slack?
No. Rollback stops new generation and restores application/database behavior, but visible messages are external state. Persist stream and destination timestamps, close or annotate unfinished messages when possible and reconcile ambiguous outcomes rather than assuming database rollback erases them.
How often should this guide be reviewed?
At least every 90 days and whenever Slack OAuth, Events API, slash commands, native stream methods, rate limits or security guidance changes; the OpenAI model/SDK/data policy changes; scopes, channels, database/proxy topology or retention changes; or an incident exposes a gap.
Recovery
Rollback
Disable new OpenAI generation while continuing fast signed Slack acknowledgements with a maintenance/human-support message, preserve delivery IDs, stream timestamps, response IDs and escalation states, restore the previous signed application release and compatible database migration state, verify OAuth fetch/signature/dedupe/thread isolation, reconcile unfinished streams and confirmed escalations, then revoke affected credentials or installations when the incident requires it.
- Set the generation kill switch and canary traffic to zero; continue acknowledging verified Slack requests quickly and direct users to the staffed human path without creating duplicate streams.
- Snapshot and protect current database metadata, record active Slack stream timestamps and confirmed escalation IDs, and stop the candidate service without deleting installations or human obligations.
- Restore the previous immutable /opt/slack-bot release and database-compatible schema/config, restart under the same non-root unit and pass signed request, OAuth fetch, channel allowlist, dedupe and thread isolation probes.
- End or annotate unfinished Slack streams when possible, reconcile any ambiguous chat.postMessage by unique escalation ID, and ensure every confirmed request is either delivered once or assigned to an operator.
- Quarantine the failed release, add regression fixtures, rotate exposed Slack/OpenAI/database/encryption credentials as applicable, revoke affected installations if token integrity is uncertain, and retain/delete exports and backups under incident policy.
Evidence