Skip to content

Run and track deploys from Slack

Build a Slack deploy bot with Chat SDK and Vercel Workflow. Dispatch GitHub Actions from a slash command, gate production behind approval, poll for completion, and notify Linear and GitHub when the run finishes.

10 min read
Last updated April 4, 2026

Build a Slack bot that orchestrates your entire deployment lifecycle in the workspace your team already uses daily.

This guide walks you through a Slack bot that orchestrates the entire deploy lifecycle from a single slash command. Type /deploy staging and the bot:

  • Dispatches a GitHub Actions workflow
  • Polls the run until it completes
  • Comments on the relevant PR(s)
  • Updates linked Linear issue(s)
  • Posts a summary card back to Slack

For production deploys, the bot gates the workflow with an approval step, so the deploy proceeds only after an authorized team member approves it.

The bot is built with Chat SDK and Vercel Workflow. Chat SDK handles the Slack interaction layer (cards, buttons, modals, and slash commands), while Vercel Workflow handles stateful orchestration (pausing for approval, polling GitHub, and resuming when events arrive). You write the deploy pipeline as a single function that pauses and resumes over minutes or hours without a database or state machine.

Deploy the template now, or read on for a deeper look at how it all works.

Chat SDK Deploy Bot Template

Trigger deploys from Slack, gate production with approval, dispatch GitHub Actions, and notify GitHub and Linear when the run finishes.

Deploy Now

You'll need accounts with these services:

  • Slack for the bot interface. Create a new app at api.slack.com/apps.
  • GitHub for workflow dispatch. You'll need a fine-grained personal access token for the target repository.
  • Redis for Chat SDK state and Vercel Workflow. Any Redis provider works. Upstash supports serverless deployments and has a free tier.
  • Linear (optional) for issue tracking. Set LINEAR_API_KEY to enable it.
  1. Create a new Slack app from a manifest at api.slack.com/apps. Use the slack-manifest.json file included in the template repo. Replace the https://example.com URLs with your production domain (e.g. https://your-app.vercel.app/api/webhooks/slack).
  2. Install the app in your workspace and copy the Bot User OAuth Token.
  3. Copy the Signing Secret from the Basic Information page.
  1. Create a fine-grained personal access token for the target repository with these permissions:
    • Actions: read and write
    • Contents: read
    • Issues: write
    • Pull requests: read
  2. Configure the token for a repository that has a workflow triggered with workflow_dispatch. Here's an example:
.github/workflows/deploy.yml
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
description: Target environment
required: true
type: choice
options:
- staging
- production
deploy_id:
description: Optional deploy correlation ID
required: false
type: string
run-name: Deploy ${{ inputs.environment }} (${{ inputs.deploy_id || github.sha }})

The deploy_id input is optional, but including it in run-name helps the bot reliably match the run it dispatched against other concurrent runs.

If you want the bot to comment on GitHub PRs as a thread (with webhook-driven replies):

  1. Add a repository webhook pointing at https://<your-domain>/api/webhooks/github
  2. Set the content type to application/json
  3. Use the same secret as GITHUB_WEBHOOK_SECRET
  4. Subscribe to issue_comment and pull_request_review_comment events

Set LINEAR_API_KEY to enable Linear integration. No separate webhook setup is required.

The bot extracts issue keys from branch names and commit messages using a team prefix (defaults to ENG, configurable via LINEAR_TEAM_PREFIX). On successful deploys, staging deploys comment on linked issues. Production deploys comment and transition issues to the state configured in LINEAR_PRODUCTION_STATE (defaults to Done).

For the bot to know which commits are new in each deploy, your deploy pipeline must maintain four git tags in the target repo:

  • deploy/staging/previous
  • deploy/staging/latest
  • deploy/production/previous
  • deploy/production/latest

The bot compares previous to latest to find the commit range. It doesn't create or move these tags itself, so your CI pipeline should update them as part of the deploy process. If the tags don't exist, the bot skips Linear updates rather than guessing.

VariableRequiredPurpose
SLACK_BOT_TOKENYesBot User OAuth Token (xoxb-...)
SLACK_SIGNING_SECRETYesRequest verification from the Basic Information page
GITHUB_TOKENYesFine-grained personal access token (github_pat_...)
GITHUB_WEBHOOK_SECRETYesSecret for verifying GitHub webhook payloads
GITHUB_REPO_OWNERYesRepository owner or organization
GITHUB_REPO_NAMEYesRepository name
GITHUB_WORKFLOW_IDYesWorkflow filename (e.g. deploy.yml) or numeric ID
REDIS_URLYesRedis connection string
LINEAR_API_KEYNoEnables Linear integration (lin_api_...)
LINEAR_TEAM_PREFIXNoIssue key prefix (default: ENG)
LINEAR_PRODUCTION_STATENoState to transition prod issues to (default: Done)
DEPLOY_PROD_ALLOWEDNoComma-separated Slack user IDs allowed to trigger prod deploys
DEPLOY_PROD_APPROVERSNoComma-separated Slack user IDs allowed to approve prod deploys

If DEPLOY_PROD_ALLOWED is empty or unset, nobody can trigger production deploys. If DEPLOY_PROD_APPROVERS is empty or unset, nobody can approve them. Staging deploys are available to everyone.

Deploy the bot with one click, or clone the repo and deploy manually:

Terminal
git clone https://github.com/vercel-labs/chat-sdk-deploy-bot.git
cd chat-sdk-deploy-bot
pnpm install
vercel

After deploying, update your Slack app's request URLs to point to your production domain: https://<your-vercel-domain>/api/webhooks/slack.

Open Slack and type:

Slack
/deploy staging

The bot should post a deploy card to the channel and dispatch your GitHub Actions workflow. You'll see status updates in the Slack thread as the run progresses, followed by a summary card when it completes.

Terminal
git clone https://github.com/vercel-labs/chat-sdk-deploy-bot.git
cd chat-sdk-deploy-bot
pnpm install
cp .env.example .env.local
pnpm dev

This starts a Next.js dev server. To receive Slack webhooks locally, use ngrok to create a public tunnel:

Terminal
ngrok http 3000

Then update your Slack app's request URLs to the ngrok URL (e.g. https://abc123.ngrok-free.dev/api/webhooks/slack).

The bot has three interfaces: Slack for user interaction, GitHub for dispatching and monitoring workflows, and (optionally) Linear for issue tracking. Here's the flow:

  1. A user types /deploy staging, /deploy production, or /deploy (which opens a modal with environment and branch options)
  2. For staging deploys, the bot posts a deploy card to Slack and immediately dispatches a GitHub Actions workflow
  3. For production deploys, the bot adds Approve and Cancel buttons to the card and pauses. The workflow only continues if an authorized approver clicks Approve
  4. Once dispatched, the bot polls the GitHub Actions run every 5 seconds for up to 60 minutes, updating a status message in Slack as it progresses
  5. When the run completes, the bot comments on associated GitHub PRs and (if Linear is enabled) comments on linked issues and transitions production issues to your configured done state
  6. The bot posts a final summary card to Slack with the environment, branch, commit, duration, linked issues, and a link to the workflow run

Vercel Workflow makes this possible. A Vercel Workflow function can suspend itself mid-execution and resume later with full state preserved. The approval gate and the polling loop are both regular code. The function pauses while waiting for a button click, resumes when it arrives, then loops while polling GitHub. No cron jobs, no queues, no external state store.

The template is a Next.js app. The bot logic lives in lib/ (setup, handlers, and integrations) and workflows/ (stateful deploy orchestration).

The bot is a Chat SDK instance with adapters for Slack, GitHub, and optionally Linear, plus Redis-backed state:

lib/bot.ts
import { Chat } from "chat";
import { createGitHubAdapter } from "@chat-adapter/github";
import { createLinearAdapter } from "@chat-adapter/linear";
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";
const adapters = {
github: createGitHubAdapter(),
...(LINEAR_ENABLED ? { linear: createLinearAdapter() } : {}),
slack: createSlackAdapter(),
};
export const bot = new Chat<typeof adapters, DeployThreadState>({
adapters,
state: createRedisState(),
userName: "deploy-bot",
}).registerSingleton();

Each deploy lives in a Slack thread with typed state (environment, branch, commit SHA, and the Slack user ID of whoever ran /deploy). This state is stored in Redis via Chat SDK's state adapter, so the approval handler and the workflow can coordinate without passing data through button payloads alone.

The bot registers a /deploy slash command with two paths. If the user provides an argument (/deploy staging or /deploy production), the bot deploys immediately on the main branch. If no argument is given, the bot opens a modal where the user can pick an environment and optionally specify a branch:

lib/deploy-handlers.ts
bot.onSlashCommand("/deploy", async (event) => {
const args = event.text.trim().toLowerCase();
if (!args) {
await event.openModal(
Modal({
callbackId: "deploy_form",
children: [
Select({
id: "environment",
label: "Environment",
options: [
SelectOption({ label: "Staging", value: "staging" }),
SelectOption({ label: "Production", value: "production" }),
],
}),
TextInput({
id: "branch",
label: "Branch",
optional: true,
placeholder: "main",
}),
],
submitLabel: "Deploy",
title: "Deploy",
})
);
return;
}
const environment =
args === "production" || args === "prod" ? "production" : "staging";
// ... permission check, payload build, workflow start
});

The bot resolves the HEAD commit for the branch, posts a deploy card to Slack, and starts the Vercel Workflow. Staging deploys are open to everyone. Production deploys are gated by DEPLOY_PROD_ALLOWED (who can trigger) and DEPLOY_PROD_APPROVERS (who can approve). When a permission check fails, the bot sends an ephemeral message visible only to the user who tried.

The deploy workflow is the core of the bot.

It's a single function, marked with "use workflow", that orchestrates the entire deploy lifecycle:

workflows/deploy-workflow.ts
export const deployWorkflow = async (rawPayload: string) => {
"use workflow";
const parsed: unknown = JSON.parse(rawPayload);
if (!isDeployWorkflowPayload(parsed)) {
throw new Error("Invalid deploy workflow payload");
}
const { thread: serializedThread, ...deploy } = parsed;
// Gate production behind approval
if (deploy.environment === "production") {
const approved = await runApprovalGate(serializedThread, deploy);
if (!approved) return;
}
// Dispatch and find the GitHub Actions run
const githubRunId = await findGitHubRun(serializedThread, deploy);
if (githubRunId === null) return;
// Poll until complete (up to 60 minutes)
const result = await pollUntilComplete(deploy, githubRunId);
// Notify Linear and GitHub
const { prCount, resolved } = await notifyExternalSystems(
serializedThread, deploy, result
);
// Post summary card
await postFinalSummary(serializedThread, deploy, result, resolved, prCount);
};

This reads like sequential code, but it may take an hour to finish. Vercel Workflow handles the suspend-and-resume mechanics. When the function calls sleep("5s") during polling, or waits for a hook event during approval, it suspends. When the timer fires or the webhook arrives, it resumes exactly where it left off with all variables intact.

For production deploys, the workflow creates a hook and waits:

workflows/deploy-workflow.ts
const runApprovalGate = async (serializedThread, deploy) => {
const { workflowRunId } = getWorkflowMetadata();
await postApprovalCard(serializedThread, deploy, workflowRunId);
using hook = createHook<ApprovalPayload>({ token: workflowRunId });
for await (const event of hook) {
if (event.approved) return true;
return false;
}
return false;
};

createHook registers a listener with a unique token (the workflow run ID). The workflow suspends at the for await loop. When someone clicks Approve in Slack, the action handler calls resumeHook with that same token, and the workflow picks up with event.approved set to true. If they click Cancel, it resumes with false and the workflow exits.

Only the person who triggered the deploy can cancel it. Anyone in the DEPLOY_PROD_APPROVERS list can approve.

The bot dispatches a workflow_dispatch event to your GitHub Actions workflow, then finds the resulting run by matching it against the branch, commit SHA, and a deploy correlation ID:

workflows/deploy-workflow.ts
const findGitHubRun = async (serializedThread, deploy) => {
const dispatch = await dispatchGitHubWorkflow(deploy);
let githubRunId = null;
for (let attempt = 0; attempt < 10; attempt++) {
await sleep("3s");
githubRunId = await findDispatchedRunOnce(deploy, dispatch);
if (githubRunId !== null) break;
}
return githubRunId;
};

The dispatch function gracefully degrades if your workflow doesn't accept all the expected inputs. It tries { environment, deploy_id } first, then { environment } alone, then no inputs at all. This makes the bot compatible with most existing deploy workflows without changes.

Once a run is found, the bot polls every 5 seconds until the run completes or 60 minutes pass. Each sleep("5s") call suspends the Vercel Workflow function, and each fetchRunSnapshot is marked with "use step" so it retries automatically if the GitHub API call fails.

On a successful deploy, the bot notifies both Linear and GitHub.

Linear issues are found by comparing deploy tags. The bot looks at the commit range between deploy/{environment}/previous and deploy/{environment}/latest in your repo, extracts Linear issue keys (like ENG-123) from branch names and commit messages, then comments on each issue with the deploy details. For production deploys, it also transitions issues to your configured done state.

GitHub pull requests associated with the deploy commit receive a comment with a summary table linking back to the workflow run.

Both steps are wrapped in "use step" directives, so they're retryable and isolated from each other. If the Linear step fails, the GitHub PR comments still proceed.

When the run completes, the bot posts a final card to the Slack thread with the environment, branch, commit, duration, linked issues, and a link to the GitHub Actions run. If Linear is enabled, the card also includes a table of issue identifiers and titles. If the deploy fails to dispatch or the run can't be matched, the triggerer is notified.

Chat SDK supports multiple platforms from a single codebase. The cards, fields, and buttons you've already defined render natively on each platform, including Block Kit on Slack, Adaptive Cards on Teams, and Google Chat Cards.

To add Microsoft Teams or another platform, register an additional adapter:

lib/bot.ts
import { createTeamsAdapter } from "@chat-adapter/teams";
export const bot = new Chat({
adapters: {
github: createGitHubAdapter(),
slack: createSlackAdapter(),
teams: createTeamsAdapter(),
},
state: createRedisState(),
userName: "deploy-bot",
});

The existing webhook route at app/api/webhooks/[platform]/route.ts already uses a dynamic segment, so Teams webhooks would be handled at /api/webhooks/teams with no additional routing code.

Modals are currently Slack-only, so the /deploy command with no arguments (which opens a modal) only works on Slack. On other platforms, require the environment argument.

See the Chat SDK adapter directory for the full list of supported platforms.

Was this helpful?

supported.

Read related documentation

Explore more Chat SDK guides