Slack

Build an AI Slack bot with CopilotKit, Copilot Runtime, createBot, the Slack adapter over Socket Mode, and interactive JSX messages rendered as Block Kit.


This guide takes you from zero to a Slack bot you can @-mention in a channel, then adds an interactive button card. You write handlers in TypeScript, Copilot Runtime hosts the agent, and rich messages are JSX that the adapter renders to Block Kit (Slack's message-UI format). No public URL needed.

Join the waitlist for managed Slack and Teams agents
Want CopilotKit Intelligence to carry Slack and Teams setup, identity and permissions, durable state, approvals, and runtime operations for you?
Join the waitlist

Prerequisites#

  • Node.js 20+
  • A Slack workspace where you can install apps
  • An OpenAI API key (or Anthropic/Google — any model supported by Model Selection)

Getting started#

Create the Slack app from a manifest#

The manifest declares everything the bot needs — scopes, events, Socket Mode, a /agent slash command, and the assistant pane ("Agents & AI Apps") — in one shot.

  1. Open api.slack.com/apps?new_app=1 and choose From a manifest.
  2. Pick your workspace.
  3. Switch the editor to the YAML tab (it defaults to JSON) and paste the contents of examples/slack/slack-app-manifest.yaml.
  4. Review and create the app.

The assistant pane is on by default

The manifest's assistant_view block (plus the assistant:write scope and the assistant_thread_started / assistant_thread_context_changed events) turns on Slack's AI assistant pane: opening it greets the user with tappable prompt chips, replies stream natively with live "is thinking…" status, and each pane conversation is its own thread. To run a plain channel bot without the pane, drop the assistant_view block and the assistant:* scope/events, and pass assistant: false to slack(...).

Install the app and copy both tokens#

The bot needs two tokens:

  1. Bot token (xoxb-…) — under OAuth & Permissions, click Install to Workspace and approve. Copy the Bot User OAuth Token that appears after the install.
  2. App-level token (xapp-…) — under Basic Information → App-Level Tokens, click Generate Token and Scopes, name it anything, add the connections:write scope, and generate. Copy the token.

No public URL needed

Socket Mode opens an outbound WebSocket to Slack — no public URL, no ngrok, works from your laptop.

Scaffold the project#

mkdir my-slack-bot && cd my-slack-bot
npm init -y && npm pkg set type=module

Install the bot packages, plus @copilotkit/runtime for Copilot Runtime and tsx to run TypeScript directly:

npm install @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-slack @copilotkit/runtime
npm install -D tsx typescript @types/node
pnpm add @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-slack @copilotkit/runtime
pnpm add -D tsx typescript @types/node
yarn add @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-slack @copilotkit/runtime
yarn add -D tsx typescript @types/node

Then create a tsconfig.json that points the JSX factory at @copilotkit/bot-ui — this is what makes <Message> / <Button> statically type-checked bot UI instead of React:

tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"],
    "jsx": "react-jsx",
    "jsxImportSource": "@copilotkit/bot-ui"
  },
  "include": ["bot.tsx"]
}

ESM only

The bot packages are ESM-only — "type": "module" (set above) is required.

Set up Copilot Runtime and the bot#

The smallest working bot is createBot + the Slack adapter + one onMention handler that runs the agent. For the quickstart, Copilot Runtime runs in the same process as the Slack bot, serves a BuiltInAgent on a local port, and the bot connects to that runtime URL:

bot.tsx
import { createServer } from "node:http";
import { createBot } from "@copilotkit/bot";
import { slack, SanitizingHttpAgent } from "@copilotkit/bot-slack"; 
import { BuiltInAgent, CopilotRuntime } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";

const agentId = "assistant";
const runtimePort = Number(process.env.RUNTIME_PORT ?? 8200);
const runtimeUrl = `http://localhost:${runtimePort}/api/copilotkit`;

// Copilot Runtime: hosts the assistant agent.
const runtime = new CopilotRuntime({
  agents: {
    [agentId]: new BuiltInAgent({
      model: process.env.OPENAI_MODEL ?? "openai:gpt-5-mini",
      prompt: "You are a helpful Slack assistant. Keep replies short.",
    }),
  },
});
createServer(
  createCopilotNodeListener({
    runtime,
    basePath: "/api/copilotkit",
    cors: true,
  }),
).listen(runtimePort, () => {
  console.log(`Copilot Runtime listening at ${runtimeUrl}`);
});

// The bot: the Slack adapter + one handler.
const bot = createBot({
  adapters: [
    slack({
      botToken: process.env.SLACK_BOT_TOKEN!, // xoxb-…
      appToken: process.env.SLACK_APP_TOKEN!, // xapp-…
    }),
  ],
  // One agent connection per Slack conversation.
  agent: (threadId) => {
    const agent = new SanitizingHttpAgent({
      url: `${runtimeUrl}/agent/${encodeURIComponent(agentId)}/run`,
    });
    agent.threadId = threadId;
    return agent;
  },
});

bot.onMention(async ({ thread }) => {
  await thread.runAgent(); 
});

await bot.start();
console.log("⚡ Bot connected over Socket Mode");

thread.runAgent() streams the agent's reply into the Slack thread, editing the message in place as tokens arrive.

Run it#

export SLACK_BOT_TOKEN=xoxb-…
export SLACK_APP_TOKEN=xapp-…
export OPENAI_API_KEY=sk-…

npx tsx bot.tsx

You should see Copilot Runtime listening at http://localhost:8200/api/copilotkit and ⚡ Bot connected over Socket Mode in the terminal. Now invite the bot to a channel and mention it — the bot's name comes from the manifest (if autocomplete can't find it, check App HomeDefault username):

/invite @YourBot
@YourBot what can you do?
Troubleshooting
  • Mentioning the bot does nothingapp_mention only fires in channels the bot is a member of: /invite it first. Also check the process is running and both tokens are set.
  • @-autocomplete doesn't find the bot — search by the bot user's Default username (visible under App Home), not the app's display name. Identity changes only propagate when you reinstall the app; in stubborn cases a full uninstall → reinstall is needed, which rotates the xoxb- token — update your env when it does.
  • bot.start() fails with an auth error — the xoxb- token is wrong or was rotated by a reinstall; copy the current one from OAuth & Permissions.
  • The bot connects but never replies — confirm OPENAI_API_KEY is set and http://localhost:8200/api/copilotkit/info returns the assistant agent.
  • Slash command does nothing — the command isn't declared in the Slack app config (see the last step), or the process isn't running.

Post interactive UI#

Replies don't have to be text. Messages are authored as JSX from the @copilotkit/bot-ui vocabulary — including buttons with inline onClick handlers. Replace the onMention handler from the previous step:

bot.tsx
import { Message, Header, Section, Actions, Button } from "@copilotkit/bot-ui"; 

bot.onMention(async ({ thread, message }) => {
  if (message.text.toLowerCase().includes("deploy")) {
    await thread.post(
      <Message accent="#27AE60">
        <Header>Deploy v1.4.2</Header>
        <Section>Ship **v1.4.2** to production?</Section>
        <Actions>
          <Button
            style="primary"
            onClick={async ({ thread }) => {
              await thread.post("🚀 Shipping!");
            }}
          >
            Ship it
          </Button>
          <Button
            onClick={async ({ thread }) => {
              await thread.post("Standing down.");
            }}
          >
            Cancel
          </Button>
        </Actions>
      </Message>,
    );
    return;
  }
  await thread.runAgent();
});

Mention the bot with "deploy" in the message and click the buttons. Your handler code never leaves your process — Slack only sees an opaque action id.

Buttons expire on restart

The default action store is in-memory: after a process restart, clicks on old buttons are acknowledged but ignored. For buttons that survive restarts, plug a durable store (Redis, a database) into createBot({ actionStore }) — see the ActionStore contract.

Add a slash command#

The manifest already declares /agent — register its handler above bot.start(). Slash-command text never appears in the channel, so pass it to the agent explicitly with prompt:

bot.tsx
bot.onCommand("agent", async ({ thread, text }) => {
  await thread.runAgent({ prompt: text }); 
});

Declare commands in the Slack app config

Slack silently drops undeclared commands — declare new ones in the manifest's slash_commands (or Slash Commands in the app settings) first.

Split the bot and the agent#

The bot and its agent talk over AG-UI through Copilot Runtime, so they don't have to share a process. The production shape is two services joined by a URL: move the CopilotRuntime block into its own process (or use a deployed Copilot Runtime) and point the bot at it via env, exactly how the full on-call triage example is wired:

bot.tsx
const runtimeUrl = process.env.COPILOT_RUNTIME_URL!;

agent: (threadId) => {
  const agent = new SanitizingHttpAgent({
    url: `${runtimeUrl}/agent/assistant/run`, 
  });
  agent.threadId = threadId;
  return agent;
},

SanitizingHttpAgent is an HttpAgent that tolerates the event streams real agent backends emit — use it over the stock HttpAgent when connecting a Slack bot to Copilot Runtime.

Platform capability matrix#

The three CopilotKit bot adapters — Slack, Discord, and Telegram — share the same @copilotkit/bot API surface but differ in what the underlying platform natively supports. The table below shows exactly what shipped in the current release.

FeatureSlackDiscordTelegram
Reactions (observe + react)✅ — add reactions:read / reactions:write scopes and subscribe to reaction_added / reaction_removed events in the app manifest✅ — requires GuildMessageReactions + DirectMessageReactions intents and Partials.Message + Partials.Reaction partials✅ — message_reaction included in TELEGRAM_ALLOWED_UPDATES; bot must be an admin in group chats
Native ephemeral✅ (chat.postEphemeral)❌ — interaction-scoped only; not wired yet❌ — not a Telegram concept
postEphemeral (with DM fallback)usedFallback: false (native)usedFallback: true (falls back to DM)usedFallback: true (falls back to DM); requires user to have previously messaged the bot
Modals✅ full — text inputs, selects, radio buttons; validation errors via response_action: "errors"; privateMetadata round-trip✅ text inputs only — max 5 fields; no validation re-open; ModalSelect / RadioButtons rejected at render time❌ — openModal resolves { ok: false }; no modal surface on Telegram

Reactions — setup notes#

Slack: subscribe to reaction_added and reaction_removed events in the Slack app manifest (or in the Event Subscriptions UI). The reactions:read scope lets the bot read reactions; reactions:write lets it add or remove them. Without the event subscriptions, Slack won't deliver reaction events even if the scopes are present.

Discord: the adapter automatically requests GuildMessageReactions and DirectMessageReactions intents (both non-privileged — no Developer Portal toggle required), and enables Partials.Message + Partials.Reaction. The partials are necessary because reaction events for messages that were sent before the bot started (or evicted from in-memory cache) arrive as partial objects; without them, those reactions are silently dropped.

Telegram: message_reaction is already included in TELEGRAM_ALLOWED_UPDATES — no manual configuration needed for long-polling. For webhook mode, pass the exported constant to setWebhook:

import { TELEGRAM_ALLOWED_UPDATES } from "@copilotkit/bot-telegram";
await bot.api.setWebhook(url, { allowed_updates: [...TELEGRAM_ALLOWED_UPDATES] });

In ordinary group chats, the bot must be an administrator to receive message_reaction events. Private chats and channels work without extra permissions.

Emoji normalization#

All three adapters normalize platform-specific emoji representations into a common emoji string. Standard named emoji (:thumbsup:, :white_check_mark:, etc.) are surfaced as their short-name. Unknown or custom emoji that cannot be mapped pass through as rawEmoji on the reaction event, so your handler can inspect them without losing information.

Modals — Discord timing rule#

Discord requires the modal to be opened before any other response to a button click or slash command. Discord's acknowledgement window is ≈3 seconds — call openModal(view) first, then do any long-running work in a follow-up message. Opening a modal after sending another response will fail silently.

Known limitations (v1)#

  • Single workspace / guild / bot — one token per adapter instance; no OAuth multi-tenant install flow
  • In-memory action store by default — inline button handlers expire on restart unless you provide a durable ActionStore
  • Replies only — the bot answers turns it's part of (mentions, its threads, DMs); it doesn't post proactively

Next steps#