references/deploy-heroku.sh
#!/usr/bin/env bash
#
# Deploy a Socket Mode Bolt app to Heroku, as a Slack CLI `deploy` hook.
#
# Installed as .slack/deploy-heroku.sh and registered as the `deploy` hook in
# .slack/hooks.json by the deploy-slack-app skill. The CLI runs it from the
# project root after installing the deployed app, so relative paths resolve
# there and SLACK_BOT_TOKEN and SLACK_APP_TOKEN are inherited from its
# environment. That handoff is not a documented contract, which is why the
# checks below exist.
#
# Supported on macOS and Linux.
set -euo pipefail
say() { printf '%s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
# The Heroku app name. When unset, reuse the app the `heroku` git remote already
# points at, so a re-deploy targets the same app even though an environment
# variable set on the first deploy is gone by the next one. The directory name
# is only the default for a first deploy. Heroku app names are global, so a
# generic one is often taken.
remote_app=$(
git remote get-url heroku 2>/dev/null \
| sed -n 's#.*/\([^/]*\)\.git$#\1#p' \
|| true
)
HEROKU_APP_NAME="${HEROKU_APP_NAME:-${remote_app:-$(basename "$PWD")}}"
# Set HEROKU_TEAM when the app has to belong to a Heroku team rather than to the
# account personally. Some accounts, including enterprise-managed ones, cannot
# own personal apps at all: `apps:create` refuses with "All apps must belong to
# a team. Create the app on a team instead." Leave empty for a personal app.
HEROKU_TEAM="${HEROKU_TEAM:-}"
# ---------------------------------------------------------------------------
# 1. Preconditions
# ---------------------------------------------------------------------------
# Both tokens are required. A Socket Mode app cannot open a websocket without an
# app-level token carrying connections:write, so a missing SLACK_APP_TOKEN is a
# hard stop rather than something to warn about and continue past.
[ -n "${SLACK_BOT_TOKEN:-}" ] || die \
"SLACK_BOT_TOKEN is not set. It is normally supplied by 'slack deploy' during app install. If you are running this script directly, export it first."
[ -n "${SLACK_APP_TOKEN:-}" ] || die \
"SLACK_APP_TOKEN is not set. Socket Mode needs an app-level token with connections:write. It is normally supplied by 'slack deploy' during app install."
command -v heroku >/dev/null 2>&1 || die \
"the heroku CLI is not installed. Install it with 'brew tap heroku/brew && brew install heroku' (macOS) or see https://devcenter.heroku.com/articles/heroku-cli, then re-run 'slack deploy'."
heroku auth:whoami >/dev/null 2>&1 || die \
"the heroku CLI is not authenticated. Run 'heroku login' (or set HEROKU_API_KEY), then re-run 'slack deploy'."
git rev-parse --git-dir >/dev/null 2>&1 || die \
"this project is not a git repository. Heroku builds from a git push, so run 'git init' and commit the project first."
# A project created inside another repository passes the check above on the
# parent's .git, and the push below would then deploy the parent's code. Compare
# physical paths, because macOS reports some directories through symlinks.
repo_root=$(cd "$(git rev-parse --show-toplevel)" && pwd -P)
[ "${repo_root}" = "$(pwd -P)" ] || die \
"this project is inside another git repository (${repo_root}) rather than being its own. Heroku would deploy that repository's code instead of this app. Run 'git init' in the project directory and commit the project there."
git rev-parse --verify HEAD >/dev/null 2>&1 || die \
"this repository has no commits yet. Heroku builds from a git push, so commit the project first."
[ -z "$(git status --porcelain)" ] || say \
"warning: there are uncommitted or untracked changes. Heroku deploys committed code only, so those changes will not go live."
git ls-files --error-unmatch Procfile >/dev/null 2>&1 || die \
"Procfile is missing or not committed. Heroku needs it to run this app as a worker rather than a web process. Create it with one line, such as 'worker: npm start' (Bolt for JavaScript) or 'worker: python app.py' (Bolt for Python), and commit it."
# ---------------------------------------------------------------------------
# 2. Report which app is being deployed
# ---------------------------------------------------------------------------
# The deploy hook receives no app ID, so read it off disk. .slack/apps.json holds
# deployed apps keyed by team ID; .slack/apps.dev.json holds the local dev app
# and is deliberately not touched here.
#
# Parsed with grep and sed rather than a language runtime: this script has to
# work in a Bolt for Python project as readily as a Bolt for JavaScript one.
app_id="unknown"
if [ -f .slack/apps.json ]; then
found=$(
tr -d '\n' < .slack/apps.json \
| grep -o '"app_id"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/.*"\([^"]*\)"$/\1/' \
| paste -sd , - \
|| true
)
[ -n "${found}" ] && app_id="${found}"
fi
say "Deploying Slack app ${app_id} to Heroku"
# ---------------------------------------------------------------------------
# 3. Provision the Heroku app
# ---------------------------------------------------------------------------
# apps:create fails outright when the app already exists, so check first. This
# is what lets a re-deploy target the same app instead of erroring.
#
# apps:info reports "Couldn't find that app" both for an app that does not exist
# and for one that exists under another account, because Heroku app names are
# globally unique across all of Heroku. A generic name taken by a stranger
# therefore reaches apps:create below and fails there on the name rather than
# here. Set HEROKU_APP_NAME to something distinctive to avoid it.
if heroku apps:info --app "${HEROKU_APP_NAME}" >/dev/null 2>&1; then
say "Reusing the Heroku app ${HEROKU_APP_NAME}"
else
say "Creating the Heroku app ${HEROKU_APP_NAME}"
if [ -n "${HEROKU_TEAM}" ]; then
heroku apps:create "${HEROKU_APP_NAME}" --team "${HEROKU_TEAM}" >/dev/null
else
heroku apps:create "${HEROKU_APP_NAME}" >/dev/null
fi
fi
# Sets or resets the `heroku` git remote to point at this app. Safe to repeat.
heroku git:remote --app "${HEROKU_APP_NAME}" >/dev/null
# ---------------------------------------------------------------------------
# 4. Configure secrets
# ---------------------------------------------------------------------------
# heroku config:set takes values as arguments only, so these two tokens are
# visible to `ps` while the command runs. The Heroku CLI offers no alternative.
say "Setting SLACK_BOT_TOKEN and SLACK_APP_TOKEN on ${HEROKU_APP_NAME}"
heroku config:set \
"SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}" \
"SLACK_APP_TOKEN=${SLACK_APP_TOKEN}" \
--app "${HEROKU_APP_NAME}" >/dev/null
# ---------------------------------------------------------------------------
# 5. Deploy
# ---------------------------------------------------------------------------
# Push the current HEAD to the app's main branch, whatever local branch the
# developer is on. The output is captured rather than piped so that a failed
# push is distinguishable from an up-to-date one.
#
# A push with no new commit reports "Everything up-to-date" and builds nothing.
# In that case the dynos are restarted below instead, which picks up any
# changed tokens. The code itself has not changed, so there is nothing to
# rebuild, and an empty commit would also sweep up anything the developer had
# staged.
say "Pushing to Heroku"
push_out=$(git push heroku HEAD:refs/heads/main 2>&1) \
|| die "the git push to Heroku failed:
${push_out}"
printf '%s\n' "${push_out}"
up_to_date=false
if printf '%s' "${push_out}" | grep -q 'Everything up-to-date'; then
up_to_date=true
fi
# Run exactly one worker and no web dyno, in one call.
#
# The Node buildpack adds a default `web` process type even though the Procfile
# only declares `worker`, and Heroku starts it on the first release. It runs the
# same start command, so it is a second copy of the app holding a second Socket
# Mode connection, stuck in a restart loop because it never binds $PORT, and
# billed as a second dyno. The worker's own logs look healthy throughout.
#
# The release starts it before this step can run, so for a few seconds after a
# first deploy both copies are connected. After this scale, web stays at 0 on
# every later release.
#
# The Python buildpack adds no `web` type, and scaling one that does not exist
# is an error, so only scale web when the formation has it.
formation=$(heroku ps:scale --app "${HEROKU_APP_NAME}" 2>/dev/null || true)
if printf '%s' "${formation}" | grep -qE '(^|[[:space:]])web='; then
say "Scaling the worker dyno to 1 and the web dyno to 0"
heroku ps:scale worker=1 web=0 --app "${HEROKU_APP_NAME}"
else
say "Scaling the worker dyno to 1"
heroku ps:scale worker=1 --app "${HEROKU_APP_NAME}"
fi
if [ "${up_to_date}" = true ]; then
say "No new commit to deploy. Restarting the dynos to pick up the current config."
heroku ps:restart --app "${HEROKU_APP_NAME}"
fi
say ""
say "Deployed. A successful build does not prove the app started, so check for"
say "the Socket Mode connection in the app's logs:"
say " heroku logs --num 100 --app ${HEROKU_APP_NAME}"
say " heroku ps --app ${HEROKU_APP_NAME}"
references/heroku.md
# Heroku
Heroku runs the app as a `worker` dyno, a process type that binds no HTTP port.
Only `web` dynos have to listen on `$PORT`, so a Socket Mode app fits the `worker`
type exactly.
The deploy script for this provider is `deploy-heroku.sh`, alongside this file.
Copy it to `.slack/deploy-heroku.sh` as described in the parent skill's Step 4.
**Cost.** Current pricing is at <https://www.heroku.com/pricing>. Point the developer
there rather than quoting or fetching it. Two things hold whatever the price list
says:
- **Who owns the app changes the bill.** Personal apps and team apps can have
different plans available and be billed differently, and a shared team has a
shared budget. Confirm with the developer which one they are creating.
- **Every running dyno counts.** That is why the deploy script makes sure no `web`
dyno runs alongside the worker.
**Three things to know before choosing Heroku.**
- **Some accounts cannot own an app personally.** Enterprise-managed Heroku accounts
are one case: `heroku apps:create` refuses with "All apps must belong to a team."
Run `heroku teams` to see which teams the account belongs to, ask the developer
which one to use, and set `HEROKU_TEAM` to it.
- **Tokens go on the command line.** `heroku config:set` has no stdin or file
input, so `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` appear as process arguments and
are visible to `ps` while the command runs. The deploy hook runs the command
rather than a person typing it, so it stays out of shell history, but the `ps`
exposure is real. Mention it to the developer.
- **Deploying means pushing a commit.** Heroku builds from git, not from the
working directory, so the project must be a git repository with at least one
commit, and the code being deployed must be committed. Uncommitted changes are
not deployed.
---
## Install and authenticate
```sh
brew tap heroku/brew && brew install heroku
heroku auth:whoami # confirms the CLI is authenticated
```
If `heroku auth:whoami` fails, ask the developer to run `heroku login` in their
own terminal. It waits for a keypress and opens a browser, so it cannot complete
inside an agent session. For a headless setup, create a token with
`heroku authorizations:create` and export it as `HEROKU_API_KEY`.
---
## What Heroku needs from the project
A committed `Procfile` in the project root declaring the `worker` process type.
Without it Heroku infers a `web` process from the start command, which is the
wrong shape: it would expect the app to bind a port and mark the deploy as failed
when it does not. Write it with the project's own start command:
```text
worker: npm start
```
For Bolt for Python, use the project's entrypoint, for example
`worker: python app.py`. Commit the file before deploying: the deploy script stops
if the `Procfile` is not in git, because an uncommitted one never reaches Heroku.
The project must be its own git repository, not a directory inside another one.
`slack create` does not run `git init`, so check with `git rev-parse --show-toplevel`
and run `git init` in the project if it names a parent directory. The deploy script
stops in that case, because the push would deploy the parent repository's code.
For Bolt for Python, also commit a `.python-version` file holding the major and minor
version the app runs on locally, such as `3.13`. Without one, Heroku picks its own
default version and warns that a missing file will become an error.
---
## Environment variables
- `HEROKU_APP_NAME` (optional): the Heroku app name. When unset, the script reuses
the app the project's `heroku` git remote points at, and on a first deploy falls
back to the project directory name. Names are globally unique across all of
Heroku and may only contain lowercase letters, digits, and dashes, so on a first
deploy pick something distinctive with the developer.
- `HEROKU_TEAM` (optional, sometimes required): the Heroku team to create the
app under. Required when the account cannot own personal apps. Leave empty for
a personal app. Only read when the app is first created.
Pass them on the deploy command, as shown in the parent skill's Step 5.
---
## Verifying
```sh
heroku ps --app <name> # expect one worker dyno, state "up"
heroku logs --num 100 --app <name> # look for the Socket Mode connection
heroku config --app <name> | cut -d: -f1 # confirms both tokens are set, without printing them
```
Do not use `heroku logs --tail`: it streams until it is stopped, which hangs an
agent session. Do not run a bare `heroku config`: it prints the token values.
**Confirm only one dyno is running.** `heroku ps` should list `worker` at 1 and no
`web` process at all. If a `web` dyno appears, the app has two copies of itself
connected to Slack and the second one is in a restart loop. Run
`heroku ps:scale web=0 --app <name>`. The worker's own logs will not reveal this:
they look healthy either way.
**Two connections right after a first deploy are expected.** For Bolt for
JavaScript, Heroku starts a `web` dyno with the first release, before the deploy
script scales it to 0. The worker's first log line can show `num_connections: 2`
while that `web` dyno shuts down. If `heroku ps` lists only the worker, there is
one copy running.
**If the chosen plan's documentation mentions dynos sleeping**, leave the app idle
for longer than the stated period, then message it. A sleeping dyno drops the
websocket and the app goes quiet in Slack.
## Tearing down
Read the app name from the `heroku` git remote, then destroy the app.
`--confirm` takes the same name and skips the confirmation prompt, and the command
also removes the `heroku` git remote:
```sh
git remote get-url heroku # https://git.heroku.com/<name>.git
heroku apps:destroy --app <name> --confirm <name>
```
references/railway.md
# Railway
Railway runs the app as a long-lived service built from the uploaded working
directory. It suits a Socket Mode app well: the service binds no HTTP port, and
Railway does not require one.
The deploy script for this provider is `deploy-railway.sh`, alongside this file.
Copy it to `.slack/deploy-railway.sh` as described in the parent skill's Step 4.
**Cost.** Current pricing is at <https://railway.com/pricing>. Point the developer
there rather than quoting or fetching it. Railway bills by resource usage, so a
long-running service costs something for every hour it is up.
---
## Install and authenticate
```sh
brew install railway # macOS; see https://docs.railway.com/guides/cli
railway whoami # confirms the CLI is authenticated
```
If `railway whoami` fails, ask the developer to run `railway login` in their own
terminal. It opens a browser, so it cannot complete inside an agent session. For
a headless setup, export `RAILWAY_API_TOKEN` with an account token. A
project-scoped `RAILWAY_TOKEN` cannot create projects, so it is not enough for a
first deploy.
---
## What Railway needs from the project
No `Procfile`, `Dockerfile`, or config file. Railway's builder detects the
language and picks a start command, so check that it will pick the right one:
- **Bolt for JavaScript:** it runs `npm start`, so `package.json` needs a
`scripts.start` entry that starts the app.
- **Bolt for Python:** it runs the first of `main.py`, `app.py`, `start.py`, or
`bot.py` that exists. The Bolt templates use `app.py`, which works unless a
`main.py` is also present. For any other entrypoint, set a start command in a
`railway.json` file (see <https://docs.railway.com/reference/config-as-code>).
---
## Environment variables
- `RAILWAY_PROJECT_NAME` (optional): the Railway project name. Defaults to the
project directory name.
- `RAILWAY_SERVICE_NAME` (optional): the Railway service name. Defaults to the
project directory name.
- `RAILWAY_WORKSPACE` (optional, sometimes required): the workspace to create the
project in. Required when the account belongs to more than one workspace,
because `railway init` otherwise prompts, and the prompt fails in a deploy
hook. `railway whoami --json` lists the account's workspaces. If there is more
than one, ask the developer which to use.
Pass them on the deploy command, as shown in the parent skill's Step 5.
---
## Verifying
```sh
railway service status --service <name> # deployment state
railway logs --service <name> --lines 100 # look for the Socket Mode connection
railway logs --build --service <name> --lines 100 # build output, if the build failed
```
Always pass `--lines`. Without it `railway logs` streams until it is stopped,
which hangs an agent session.
**Two connections right after a re-deploy are expected.** Railway starts the new
deployment before removing the old one, so the new one's logs can show
`"num_connections":2` for a moment. `railway deployment list --service <name>`
should then show the new deployment as `SUCCESS` and the old one as `REMOVED`.
If two deployments stay `SUCCESS`, two copies of the app are connected to Slack.
Expect the build to take a few minutes on the first deploy. Railway injects a
`PORT` variable even when nothing listens on it, which is harmless for a Socket
Mode app.
## Tearing down
Read the project name from `railway status`, then delete the project and unlink
the directory:
```sh
railway status # shows the linked project
railway delete --project <name> --yes
railway unlink --yes
```
Railway stops the service right away but schedules the project's removal for
later, so the project stays in `railway list` for a while. `railway list --json`
shows the scheduled date as `deletedAt`. The project being listed does not mean
the delete failed.
SKILL.md
---
name: deploy-slack-app
description: Use when a developer wants to deploy, host, or run a Slack app somewhere it keeps working after the local process stops, or mentions Railway, Heroku, `railway up`, `git push heroku`, `slack deploy`, a `deploy` hook in `.slack/hooks.json`, hosting a Socket Mode or Request URL (HTTP) Bolt app, deploying from Windows, graduating an app out of a developer sandbox, or tearing down a deployed app.
---
# Deploy Slack App
Take a Slack app that already works locally and deploy it to a hosting provider as a separate Slack app, with its own app ID, that keeps running without the developer's machine. The local development app is unchanged, and **Step 5: Deploy** explains how the two coexist. This is the step after the `test-slack-app` skill confirms the app responds.
The mechanism is the Slack CLI's `deploy` hook. Adding a `deploy` key to the project's `.slack/hooks.json` replaces Slack's own hosted deployment with a script of your choosing, and the CLI runs that script _after_ it has created and installed the deployed app. That ordering is the whole reason this approach is worth using: the app's bot and app-level tokens are already in the script's environment by the time it runs, so nothing here has to prompt for a secret or store one.
Two supported providers, both running the app as a long-lived worker process:
| Provider | Process type | Deploys from | Secrets | Current pricing |
| --- | --- | --- | --- | --- |
| Railway | Service, no port binding required | The working directory | Passed on stdin | <https://railway.com/pricing> |
| Heroku | `worker` dyno | A git push of committed code | Passed as arguments | <https://www.heroku.com/pricing> |
Railway is the better default, and the reasons are in **Step 2a: Choose a Provider**.
---
## Step 1: Check Prerequisites
### 1a. Detect the Slack CLI
Load the `slack-cli` skill (with your skill tool, if you have one) before running any command, and follow its **Step 1: Detect the Slack CLI** to check whether the public Slack CLI is installed and resolve its command name. Don't check for the CLI yourself first: a `slack` on `PATH` isn't always the public CLI, and that step tells them apart. The fingerprint check, alias fallback, and install instructions all live there; do not duplicate them. Refer to the resolved command as `SLACK_CMD` throughout.
This matters more than it looks. Some machines have an unrelated internal tool named `slack` on the path, and running that instead produces confusing failures well into the deploy.
### 1b. Confirm the App Uses Socket Mode
**Read the app's manifest rather than asking.** The project already declares which mode it uses, so a question here is redundant. Check `manifest.json` for `settings.socket_mode_enabled`. If `.slack/config.json` sets the manifest source to `remote`, there is no local file, so read it with `SLACK_CMD manifest info` instead.
- **Socket Mode enabled:** continue. The app opens an outbound websocket to Slack, needs no public URL, and binds no HTTP port.
- **Socket Mode disabled, or the app uses a Request URL:** **stop here.** This skill does not cover Request URL apps yet, because a hosted Request URL app also needs a public address captured after the deploy and written back into the app manifest. Tell the developer plainly that this is the gap, and that converting the app to Socket Mode is the supported path today. Support may have shipped in a newer version of the Slack Skills Plugin, so ask the developer to update the plugin and try again. If they are already on the latest version, point them to <https://github.com/slackapi/slack-skills-plugin/issues/172> to add a reaction or comment.
If the manifest is ambiguous, read the app's entrypoint. A Bolt for JavaScript app constructed with `socketMode: true` and an `appToken`, or a Bolt for Python app started through `SocketModeHandler`, is a Socket Mode app.
### 1c. Confirm the Runtime Is Supported
This flow is verified on macOS and Linux. The deploy hook is a shell script, so a Windows developer needs a PowerShell equivalent that does not exist yet. On Windows, say so rather than letting the deploy fail partway through. WSL is a workable path in the meantime. Support may have shipped in a newer version of the Slack Skills Plugin, so ask the developer to update the plugin and try again. If they are already on the latest version, point them to <https://github.com/slackapi/slack-skills-plugin/issues/171> to add a reaction or comment.
---
## Step 2: Choose a Provider and a Team
Ask the developer two things the project cannot tell you: which provider to deploy to, and which Slack team to deploy into.
### 2a. Choose a Provider
Ask which provider, because cost is the thing the developer cannot infer from the project. Give the developer both pricing links from the table above so they can check current terms. **Do not fetch the pages or quote prices, plan names, or trial terms.** Providers change them often, and this skill can be installed long after it was written.
What does not change with the price list, and is worth saying:
- **Railway** is the default recommendation. It accepts secrets on stdin, so the tokens never appear in process arguments. It deploys the working directory, so a re-deploy needs no commit, and it needs no `Procfile`.
- **Heroku** fits when the developer already has an account, or a team standard that points there. It builds from git, so only committed code is deployed. Some accounts, including enterprise-managed ones, cannot own a personal app and must create it on a Heroku team. Personal apps and team apps can have different plans and billing, so ask which team to use rather than picking one: a shared team has a shared budget.
Say plainly that **both providers keep the app running continuously**, which is what a Socket Mode app requires. Always-on hosting is a paid service in the end, so expect it to cost something once any trial runs out.
Once the developer picks one, read that provider's reference file and follow it: `references/railway.md` or `references/heroku.md`. Each holds the install and authentication commands, the provider's own requirements for the project, and the environment variables its deploy script reads. **Step 4: Wire Up the Deploy Hook** works through them.
### 2b. Choose a Team
Run `SLACK_CMD auth list`. If it lists one team, use it. If it lists more than one, show the developer each team's name and ID and ask which one to deploy into.
**Never pick the team yourself.** Do not assume the standalone workspace or the Enterprise org, and do not reuse the team from `.slack/apps.dev.json`, which is often a developer sandbox. A wrong guess installs the app into a team the developer did not intend, and a later deploy to the right team creates a second Slack app.
---
## Step 3: Confirm It Works Locally First
**Do not deploy an app that has not been seen working.** A broken deploy and a broken app look identical in a provider's logs, and separating them afterwards costs far more than checking first.
Use the `test-slack-app` skill to run the app with `SLACK_CMD run` and exercise at least one real surface, so there is a known-good behaviour to re-check after the deploy. Note which surface was used: the same one is the check in **Step 6: Verify the Deployment**.
---
## Step 4: Wire Up the Deploy Hook
Three things to set up. **Check whether each already exists before writing it**, because re-deploying an app is the common case and clobbering a developer's edited script is not recoverable.
### 4a. Prepare the Provider
Work through the chosen provider's reference file before writing anything, because the deploy script only checks these and stops. It cannot fix them.
- **Install and authenticate the provider's CLI.** Run its `whoami` check. Logging in opens a browser, so if the check fails, ask the developer to run the login command in their own terminal rather than running it yourself.
- **Settle the names and ownership with the developer.** For Heroku: a distinctive app name, and whether the app belongs to a team (`heroku teams`). For Railway: the workspace, if the account has more than one. These become environment variables on the deploy command in **Step 5: Deploy**.
- **Meet the provider's project requirements.** For Heroku, write the `Procfile` and commit it. For Railway, confirm the start command it will detect.
### 4b. Write the Deploy Script
Copy the provider's deploy script into the project's `.slack/` directory under the same name, then make it executable:
- Railway: `references/deploy-railway.sh` to `.slack/deploy-railway.sh`
- Heroku: `references/deploy-heroku.sh` to `.slack/deploy-heroku.sh`
For example, `chmod +x .slack/deploy-railway.sh`.
Each script is self-contained. It validates that both tokens arrived, reads the deployed app ID from `.slack/apps.json` for its log line, and then runs the provider's preflight, provision, configure, and deploy steps in order. There is nothing else to copy. The CLI runs the hook from the project root, so the script's relative paths resolve there even though the file lives in `.slack/`.
**If the script already exists**, show the developer that it is there and ask before overwriting. A developer may have adjusted it.
### 4c. Register the Hook
Add a `deploy` key inside the existing `hooks` object in `.slack/hooks.json`, pointing at the provider's script. For Railway:
```json
"deploy": "./.slack/deploy-railway.sh"
```
For Heroku, the value is `./.slack/deploy-heroku.sh`. **Leave the `get-hooks` entry exactly as it is.** Its value differs between Bolt for JavaScript and Bolt for Python projects, so do not copy one from elsewhere.
**If a `deploy` key is already present, do not add a second one.** A duplicate key produces invalid JSON, and the CLI will reject the project rather than deploy it. If the existing key already points at the chosen provider's script, leave it. If it points somewhere else, such as the other provider's script, show the developer and ask before changing it.
---
## Step 5: Deploy
Run `SLACK_CMD deploy` from the project root and watch the output. The CLI creates the deployed app, installs it, and then runs the hook script, so the first part of the output is app installation and the second is the provider's build.
Four flags matter when running this outside an interactive terminal, which includes most agent sessions. The CLI asks four questions, and each one is fatal unanswered: a non-interactive shell gets `The input device is not a TTY or does not support interactivity` and nothing is deployed.
- `--skip-update` stops the CLI pausing to offer an upgrade.
- `--team <team ID>` picks the workspace or organization. Use the team the developer chose in **Step 2b: Choose a Team**.
- `--app deployed` selects the deployed app environment, and creates that app on a first deploy. Without it the CLI asks the developer to choose between an existing app and a new one. This is the flag most easily missed, because the prompt it answers only appears on a project that has never been deployed.
- `--org-workspace-grant all` answers the workspace-grant prompt for an org-installed app.
So the full command is `SLACK_CMD deploy --skip-update --team <team ID> --app deployed --org-workspace-grant all`.
Put any provider variables settled in **Step 4a: Prepare the Provider** in front of it. The hook inherits the CLI's environment, so this is how they reach the script:
```sh
HEROKU_TEAM=<team> HEROKU_APP_NAME=<name> SLACK_CMD deploy --skip-update --team <team ID> --app deployed --org-workspace-grant all
```
**The deployed app is a different app from the one `SLACK_CMD run` uses.** The CLI writes the deployed app to `.slack/apps.json` and the local development app to `.slack/apps.dev.json`, keyed by team. Both keep working independently, which is the intended design, not a mistake to correct.
**Commit `.slack/apps.json` after the first deploy.** It holds app and team IDs, not secrets, and the project's `.slack/.gitignore` leaves it tracked on purpose. Committing it keeps every later deploy pointed at the same app. On Heroku it also stops the deploy script warning about uncommitted changes on each re-deploy.
One consequence to warn the developer about: the two apps declare the same slash commands in the same workspace. Running `SLACK_CMD run` while the deployed app is live makes it ambiguous which one answers a slash command. Stop the local process when checking the deployed app, which **Step 6: Verify the Deployment** does anyway.
---
## Step 6: Verify the Deployment
Check all three of these. The first two can pass while the app is silent in Slack.
### 6a. The Service Is Running
Use the status and log commands from the provider's reference file. Look for the app's own startup logging and the Socket Mode websocket connecting. A build that succeeded and a process that then exited look very different here, so read the logs rather than trusting the build result.
### 6b. The App Answers in Slack, With Nothing Running Locally
**Stop the local process first.** This is the only check that proves the deployed app is doing the work rather than the developer's laptop. Exercise the surface noted in **Step 3: Confirm It Works Locally First**, and confirm the response appears in Slack and the corresponding request appears in the provider's logs.
### 6c. Both Apps Are Distinct
Show the developer the app ID in `.slack/apps.json` and the one in `.slack/apps.dev.json` and confirm they differ. Then confirm `SLACK_CMD run` still drives the development app.
---
## Step 7: Re-deploy
Shipping a change is the same `SLACK_CMD deploy` command, with the same flags as **Step 5: Deploy**. Everything in **Step 4: Wire Up the Deploy Hook** is already in place, so skip it and go straight to the deploy, and expect the same app rather than a second one.
Two provider differences to know about, both handled by the deploy scripts:
- **Railway** uploads the working directory and rebuilds every time, so no commit is needed.
- **Heroku** builds from a git push, so uncommitted changes are not deployed. A push with no new commit builds nothing, so the script restarts the dynos instead, which picks up any changed tokens. Commit code changes before re-deploying.
If a re-deploy creates a second Slack app, the deployed app entry in `.slack/apps.json` was lost. Before deploying again, confirm `.slack/apps.json` has an entry for the `--team` ID, rather than deleting apps afterwards.
---
## Step 8: Tear Down a Deployment
Use this when the developer is done with the deployed app, for example after trying the skill out. It deletes the host's copy and the deployed Slack app, and leaves the development app alone.
**Name every resource and ask once before deleting anything.** These deletes are permanent. Say which provider project or app goes, and which Slack app ID from `.slack/apps.json`, then wait for a yes.
1. **Delete the provider's copy first**, so nothing is left holding a connection to Slack with tokens that are about to stop working. The commands are in the provider's reference file, under "Tearing down".
2. **Delete the deployed Slack app** with `SLACK_CMD app delete --team <team ID> --app deployed --force`. Read the team ID from `.slack/apps.json`. `--force` skips the confirmation prompt, which would otherwise fail in a non-interactive shell. When it was the only deployed app, the CLI removes `.slack/apps.json` as well, so commit that deletion.
3. **Leave the development app** unless the developer asks for it too. It is the app `SLACK_CMD run` uses, and they will usually keep working on it. If they ask, the same command with `--app local` deletes it.
Leave `.slack/hooks.json` and the deploy script in place, so a later `SLACK_CMD deploy` sets everything up again from the start.
---
## Notes
**Serverless providers cannot host a Socket Mode app.** A Socket Mode app is a process that stays resident and holds an outbound websocket open. Vercel, AWS Lambda, and similar platforms run per-request functions with a maximum duration and no always-on process type, so there is nothing for the websocket to live in. When a developer asks for one of these, explain the constraint rather than attempting it. Hosting a Slack app on a serverless platform means a Request URL app, which this skill does not cover yet.
**Other providers.** When a developer asks about a provider this skill does not cover, the two things to check are whether it offers an always-on background worker process type and how that process is billed.
**The token handoff is not a documented contract.** The Slack CLI does not pass the tokens to the deploy hook explicitly. It sets them on its own process during app installation, and the hook script inherits them because it runs later in that same process. It works, and it only works for apps with no Slack-hosted function runtime, which covers every Bolt app. That is why each deploy script checks for both tokens and stops with a readable message instead of assuming they are present.
**Not `slack deploy` without a hook.** With no `deploy` key in `.slack/hooks.json`, `SLACK_CMD deploy` targets Slack's own hosted infrastructure, which is a different product for a different kind of app. The `slack-cli` skill covers the CLI's commands generally.
**Building the app comes first.** If there is no app yet, start with the `create-slack-app` skill and come back here once it runs locally.