showcase/shell-docs/src/content/docs/frontends/slack.mdx
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.
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](https://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`](https://github.com/CopilotKit/CopilotKit/blob/main/examples/slack/slack-app-manifest.yaml).
4. Review and create the app.
<Callout type="info" title="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(...)`.
</Callout>
</Step>
<Step>
### 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.
<Callout type="info" title="No public URL needed">
Socket Mode opens an outbound WebSocket to Slack — no public URL, no ngrok, works from your laptop.
</Callout>
</Step>
<Step>
### Scaffold the project
```bash
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:
<Tabs groupId="package-manager" items={['npm', 'pnpm', 'yarn']}>
<Tab value="npm">
```bash
npm install @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-slack @copilotkit/runtime
npm install -D tsx typescript @types/node
```
</Tab>
<Tab value="pnpm">
```bash
pnpm add @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-slack @copilotkit/runtime
pnpm add -D tsx typescript @types/node
```
</Tab>
<Tab value="yarn">
```bash
yarn add @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-slack @copilotkit/runtime
yarn add -D tsx typescript @types/node
```
</Tab>
</Tabs>
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:
```json title="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"]
}
```
<Callout type="info" title="ESM only">
The bot packages are ESM-only — `"type": "module"` (set above) is required.
</Callout>
</Step>
<Step>
### 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:
```tsx title="bot.tsx"
import { createServer } from "node:http";
import { createBot } from "@copilotkit/bot";
import { slack, SanitizingHttpAgent } from "@copilotkit/bot-slack"; // [!code highlight]
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: [
// [!code highlight:4]
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(); // [!code highlight]
});
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.
</Step>
<Step>
### Run it
```bash
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 Home** → *Default username*):
```
/invite @YourBot
@YourBot what can you do?
```
<Accordions className="mb-4">
<Accordion title="Troubleshooting">
- **Mentioning the bot does nothing** — `app_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.
</Accordion>
</Accordions>
</Step>
<Step>
### Post interactive UI
Replies don't have to be text. Messages are authored as JSX from the [`@copilotkit/bot-ui` vocabulary](/reference/bot/components/Message) — including buttons with **inline `onClick` handlers**. Replace the `onMention` handler from the previous step:
```tsx title="bot.tsx"
import { Message, Header, Section, Actions, Button } from "@copilotkit/bot-ui"; // [!code highlight]
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.
<Callout type="warn" title="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](/reference/bot/types/ActionStore).
</Callout>
</Step>
<Step>
### 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`:
```tsx title="bot.tsx"
bot.onCommand("agent", async ({ thread, text }) => {
await thread.runAgent({ prompt: text }); // [!code highlight]
});
```
<Callout type="warn" title="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.
</Callout>
</Step>
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:
const runtimeUrl = process.env.COPILOT_RUNTIME_URL!;
agent: (threadId) => {
const agent = new SanitizingHttpAgent({
url: `${runtimeUrl}/agent/assistant/run`, // [!code highlight]
});
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.
ActionStore