
Getting any agent into messaging platforms like Slack is complex. You create a Slack app, handle its events, format messages to the platform's format, handle deliveries with retries, and so on.
Taking that to production is much harder. Then, if you want to bring it into another messaging platform, you do most of the work again, and again for the next platform.
To remove that manual work, we're excited to release the Channels SDK, which brings any AG-UI agent to any channel from one codebase, with a persistence layer so it keeps its memory and context.
Today, we will bring an agent into Slack, learn how everything works along with the architecture, the core patterns and connecting it to real tools like Notion using MCP.
npx copilotkit@latest channels setup
To try the agent live, we have created public channels on Slack and MS Teams. Head to copilotkit.ai/try-channels and we'll get you in.
What you will build
A Slack bot running on your own agent, that can:
- answer in a thread with the context already around it
- act through real tools (Notion over MCP)
- reply with native cards
- ask for approval before it changes anything
- read files and images people drop in
- remember a user across conversations and platforms
Prefer a complete working example? Clone OpenTag on GitHub.
If you want to explore on your own, read the Channels docs, or copy a ready-made prompt from the docs homepage and your coding agent builds your first channel with you.
What is the Channels SDK?
The Channels SDK is an open source TypeScript library that takes any AG-UI agent into Slack, Microsoft Teams, Discord, Telegram, WhatsApp and other channels, from one codebase.
npm i @copilotkit/channels
It's built on the AG-UI protocol, so the app and the agent stay decoupled.
You can bring any AG-UI compatible agent framework (LangChain, Google ADK, Mastra, Pydantic AI, Claude Agent SDK, etc.) and swap it later without touching the channel code.
And if you don't want to bring your own framework, we ship a BuiltInAgent that runs on any model, hosted or local (via Ollama, LM Studio, or vLLM). We will be using this for the setup.
You write replies as <Message> JSX, and each platform renders them natively: Block Kit in Slack, Adaptive Cards in Teams.
Managed Path for Slack and Microsoft Teams
Getting an agent to your channels usually involves:
- provider setup, tokens, and permissions
- webhook or socket connection
- durable state so threads resume on the right agent
- reliable delivery with retries
That's the direct path, and we do ship adapters for it. But to run in production, we give you managed Slack and Microsoft Teams today with CopilotKit Intelligence.
It holds the connection and delivers each turn to your app, so your process just runs the agent, with no public URL to expose, and adding a platform is a click in the dashboard.
This guide uses the managed path, since it's the one you run in production. The integration code is mostly identical either way.
The diagram below shows the boundary between your app and CopilotKit Intelligence, and the path each message takes.
Channels UI
There's a component library with the building blocks: Message, Section, Header, Table, Image, Chart, Actions, Button, and more to help you compose UI blocks easily.
Put a few together and you get a rich reply, rendered natively for you.
import { Message, Header, Section, Context } from "@copilotkit/channels/ui";
<Message>
<Header>Deploy complete</Header>
<Section>v1.4.2 is live in production.</Section>
<Context>Shipped by the deploy bot Β· 12:04 PM</Context>
</Message>
Complete setup: Slack agent
You'll need Node.js 22+, a Slack workspace where you can install apps, a free CopilotKit Intelligence key, and a model API key like OpenAI.
Here is the project structure. As we add capabilities below (tools, cards, MCP), we will do it in components.tsx for the cards and buttons, and mcp.ts for the Notion client. Everything else stays as is.
slack-bot/
βββ agent.ts # BuiltInAgent + your model
βββ channel.tsx # the app
βββ tsconfig.json # JSX transform pointed at @copilotkit/channels
βββ package.json
βββ .env # INTELLIGENCE_API_KEY, ...
Step 1: Create the channel and connect Slack
In the Intelligence dashboard, create a channel, choose the platform and set its name like support-slack. Your app declares this exact name later.
Intelligence generates a Slack app manifest. Create the Slack app from it at api.slack.com/apps, install it, and paste two values back into Intelligence:
- the Bot token (
xoxb-β¦), from OAuth & Permissions - the Signing Secret, from Basic Information
Both are required. The bot token lets Intelligence post as your app and the signing secret lets it verify Slack's events. Without the secret, events are never delivered.
Once you create the channel, the dashboard will look like this.
Step 2: Turn on interactivity and scopes
The manifest you installed already turns these on, so there's nothing to add. It's worth knowing what they do, since the features later in this guide depend on them.
- Interactivity is on, which is what makes buttons and approvals work.
- The scopes are set to enable memory, read uploaded files and remember context.
If you ever build the Slack app manually instead of from the manifest, this is where you'd enable those, then reinstall the Slack app in your workspace.
Step 3: Create the agent
The built-in agent runs inside your app, so there's no second server to run. Here's the code.
// agent.ts
import { BuiltInAgent } from "@copilotkit/runtime/v2";
export function makeAgent(threadId: string) {
const agent = new BuiltInAgent({ model: "openai/gpt-5.5" });
agent.threadId = threadId;
return agent;
}
The model string is provider/model. It works with OpenAI, Anthropic, and Google out of the box, and with any OpenAI-compatible endpoint, so you can point it at a local model (Ollama, a self-hosted gateway) instead of a hosted one. See Model Selection for the local and custom-provider setup.
Step 4: Set up the app
Initialize the project and install the SDK, plus the dev tools to run TypeScript.
npm init -y && npm pkg set type=module
npm install @copilotkit/channels @copilotkit/runtime
npm install -D tsx typescript @types/node dotenv
Channels JSX does not use React. Point the JSX transform at @copilotkit/channels so <Message> and <Button> become type-checked Slack UI instead of React elements. Put JSX in .tsx files and include them.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"jsxImportSource": "@copilotkit/channels",
"strict": true,
"noEmit": true
},
"include": ["*.ts", "*.tsx"]
}
Note: If a component throws "React is not defined," it's because the JSX fell back to React. The jsxImportSource line above is what prevents it.
Create a .env in the root and add your API keys. You can create a free CopilotKit Intelligence key from the dashboard.
INTELLIGENCE_API_KEY=cpk-β¦
INTELLIGENCE_CHANNEL_NAME=toothless
OPENAI_API_KEY=sk-β¦
PORT=3000
Step 5: Build the app
The app ties the two sides together: createChannel sets it up, one onMessage handler runs the agent, and a listener keeps the process alive.
Here's what the code does:
createChanneldeclares the bot, which channel, which agent, andstore.onMessageruns the agent on each message and streams the reply back to the thread.The runtime and listener connect to Intelligence with your API key and keep the process alive.
// channel.tsx
import "dotenv/config";
import { createServer } from "node:http";
import { createChannel } from "@copilotkit/channels";
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
import { makeAgent } from "./agent";
const channel = createChannel({
name: process.env.INTELLIGENCE_CHANNEL_NAME!, // the channel Code from step 1
identifyUser: "platform",
agent: makeAgent,
// Drop duplicate deliveries so a Slack retry can't double-run the agent.
store: { concurrency: "drop", lockTtl: 60_000, dedupTtl: 300_000 },
});
channel.onMessage(async ({ thread, message }) => {
await thread.runAgent({ prompt: message.text });
});
const intelligence = new CopilotKitIntelligence({ apiKey: process.env.INTELLIGENCE_API_KEY! });
const runtime = new CopilotRuntime({ agents: {}, intelligence, channels: [channel] });
const listener = createCopilotNodeListener({ runtime, basePath: "/api/copilotkit" });
await listener.channels?.ready({ timeoutMs: 30_000 });
createServer(listener).listen(Number(process.env.PORT ?? 3000));
console.log("Channel online.");
Slack retries any message it doesn't get a quick reply for, and without a store those retries turn into duplicate runs. The store keeps one run per conversation and drops the repeats.
Step 6: Run it
The agent runs inside the app, so one command starts everything.
node --env-file=.env --import tsx channel.tsx
If you have followed the steps, the channel status should change from "Waiting for runtime" to "Online".
Invite the app to a channel and mention it. It should reply in threads accordingly.
/invite @your-app
@your-app what can you help with?
That's the whole setup. Slack application code on one side, your agent on the other.
Now let's make the agent actually useful by implementing Generative UI, Human-in-the-loop, MCP Tools, Threads and more.
Generative UI: reply with native cards
Generative UI lets your Slack agent create interfaces on the fly. Instead of answering in text, it builds what the moment needs and posts it into the thread.
Native cards make it possible. You write them as JSX from @copilotkit/channels/ui, and they render as real, interactive Slack UI: headers, sections, buttons, and more.
/** @jsxImportSource @copilotkit/channels */
import { Message, Header, Section, Context } from "@copilotkit/channels/ui";
export function TaskCard({ task, owner, priority, status }: TaskRow) {
return (
<Message>
<Header>{`π΄ ${task}`}</Header>
<Section>{`*${priority}* Β· ${status}`}</Section>
<Context>{`π€ ${owner}`}</Context>
</Message>
);
}
Native components cover more than text. Alongside cards, you get charts, tables, and images, all rendered natively in Slack. Here's a chart:
/** @jsxImportSource @copilotkit/channels */
import { Chart } from "@copilotkit/channels/ui";
export function CloseTimeChart({ rows }: { rows: { area: string; avgDays: number }[] }) {
return (
<Chart
type="verticalBar"
title="Avg days to close, by area"
xAxisTitle="Area"
yAxisTitle="Days"
data={rows.map((r) => ({ label: r.area, value: r.avgDays }))}
/>
);
}
The agent fills the data from the conversation, so "chart close times by area" becomes real bars in the thread.
An image is for what native components can't draw, a flowchart of what the team just brainstormed, or a fully styled widget. You render it to a PNG and post it as a file, and Intelligence hosts it.
const png = await renderFlowchart(triage); // mermaid or HTML β PNG
await thread.postFile({
bytes: png,
filename: "flow.png",
title: "Docs issue backlog triage flow",
altText: "Triage path from raw issue state to the main backlog risk",
});
Read more on the Rich messages docs.
Give the agent a tool
An agent with only a model can answer from what it knows. A tool lets it call your code, hit an API, run a search, read from your database, and use the result in its reply.
You define one with defineChannelTool: the parameters schema becomes the tool's input, and your handler runs when the agent calls it.
Here's the trimmed snippet showing the flow - you can use Tavily or another suitable provider for web search.
// tools.ts
import { defineChannelTool } from "@copilotkit/channels";
import { z } from "zod";
export const webSearch = defineChannelTool({
name: "web_search",
description: "Search the web and return the top results.",
parameters: z.object({
query: z.string().describe("What to search for"),
}),
async handler({ query }) {
const results = await search(query); // your search call, e.g. Tavily
return results; // objects are serialized for the model
},
});
Register it on the channel:
const channel = createChannel({
name: process.env.INTELLIGENCE_CHANNEL_NAME!,
identifyUser: "platform",
agent: makeAgent,
tools: [webSearch],
});
Now "search for the latest Anthropic Claude news and summarize it" runs your web_search, reads the results, and answers in the thread with that context.
A few rules of thumb from the API: return raw objects or arrays for data tools, return a short confirmation when the handler already posted UI, and throw the real error so the agent can recover, avoid JSON.stringify on your own success data.
Read Tools and context docs.
Connect a tool with MCP: a Notion example
defineChannelTool is for actions you write yourself. To give the agent a whole set of tools something else already exposes, connect an MCP server, and it can search a knowledge base, create a ticket, or update a page.
Let's connect Notion MCP for instance.
- Create an internal integration at notion.so/profile/integrations and copy its token (
ntn_β¦). - Open a Notion page, top-right β’β’β’ β Connections β add your integration. The agent sees only the pages you connect.
Add the token to .env.
NOTION_TOKEN=ntn_...
Then wire the Notion MCP server as one of the agent's MCP clients. It runs as a local process (npx @notionhq/notion-mcp-server) and hands the agent Notion's tools: search, fetch, create, update.
import { BuiltInAgent } from "@copilotkit/runtime/v2";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
StdioClientTransport,
getDefaultEnvironment,
} from "@modelcontextprotocol/sdk/client/stdio.js";
function notionMcp() {
const client = new Client({ name: "my-bot", version: "0.1.0" }, { capabilities: {} });
return {
async tools() {
await client.connect(
new StdioClientTransport({
command: "npx",
args: ["-y", "@notionhq/notion-mcp-server"],
// Forward env, or NOTION_TOKEN never reaches the spawned server.
env: { ...getDefaultEnvironment(), ...process.env } as Record<string, string>,
}),
);
const { tools } = await client.listTools();
// Expose each MCP tool to the agent (full mapping in the repo's mcp.ts).
return toToolSet(client, tools);
},
};
}
export function makeAgent(threadId: string) {
const agent = new BuiltInAgent({
model: "openai/gpt-5.5",
maxSteps: 10, // let it call a tool, read the result, then answer
mcpClients: [notionMcp()],
});
agent.threadId = threadId;
return agent;
}
Now "what's in my Q3 roadmap?" searches Notion and answers from the real page. If the page isn't connected to the integration, the agent says it can't find it instead of guessing. You can enable generative UI as well if you want to reply in a structured way.
Here's an example of fetching Linear tickets, with a similar pattern.
Human-in-the-loop: approve before the agent acts
Some actions shouldn't happen without a person approving them. For those critical actions, the agent can do it via human-in-the-loop.
You author the prompt as a card with two buttons, and the click carries your own value back to the handler.
/** @jsxImportSource @copilotkit/channels */
import { Message, Header, Actions, Button, type InteractionContext } from "@copilotkit/channels/ui";
// The click runs in your runtime; the platform only sends back the button's value.
async function onDecision({ action, thread }: InteractionContext<{ ok: boolean; what: string }>) {
const v = action.value;
if (!v) return; // the click value can be undefined, so guard it first
await thread.post(v.ok ? `Approved: ${v.what}` : "Cancelled.");
if (v.ok) {
await thread.runAgent({ prompt: `The user approved "${v.what}". Do it now, then confirm.` });
}
}
export function ConfirmAction({ what }: { what: string }) {
return (
<Message>
<Header>{`Approve: ${what}`}</Header>
<Actions>
<Button value={{ ok: true, what }} style="primary" onClick={onDecision}>Approve</Button>
<Button value={{ ok: false, what }} style="danger" onClick={onDecision}>Deny</Button>
</Actions>
</Message>
);
}
Tell the agent to ask for approval before any write. It posts the prompt, pauses, and continues only once approved. Read more on the Interactive messages docs.
You can also add more context using the what field.
Read files and images users upload
People drop files into Slack, screenshots, PDFs, logs. The agent forwards what the message carries into the run and the attachment goes with the text.
channel.onMessage(async ({ thread, message }) => {
await thread.runAgent({
prompt: message.contentParts?.length
? [{ type: "text" as const, text: message.text }, ...(message.contentParts ?? [])]
: message.text,
});
});
Here's an example of an image attached and the agent responds accordingly.
Memory: conversation history and cross-platform transcripts
The agent remembers in two ways.
- Conversation history is the agent recalling the current thread. Intelligence seeds each turn with recent messages, so a follow-up in the same thread has context. This is automatic, you store nothing.
- Transcripts are the SDK's own memory, kept per user across platforms. Someone who asked in Teams can follow up in Slack, and the agent still has the earlier context.
Transcripts are keyed to a user, so you give the channel a stable identity, then turn transcripts on in the store.
const channel = createChannel({
name: process.env.INTELLIGENCE_CHANNEL_NAME!,
identifyUser: ({ actor }) =>
actor.email ? { id: actor.email, name: actor.name ?? actor.email } : null, // needs users:read.email
agent: makeAgent,
store: {
concurrency: "drop", lockTtl: 60_000, dedupTtl: 300_000,
adapter: new MyStore(), // Redis, Postgres
transcripts: { retention: "30d", maxPerUser: 200 },
},
});
Then call thread.runAgent({ prompt, transcript: true }), and prior history is injected when the thread has a resolved user.
Transcripts and pending approvals live in this store, separate from the thread Intelligence keeps. In development, it's in memory and resets on restart, so for production use a durable store like Redis or Postgres.
Yay! Now your agent is very powerful and can do a lot of tasks, with the context of your channel.
From the Intelligence dashboard, you can see all the threads, usage history, runs and complete agent lifecycle per thread via AG-UI events.
Swap in your own agent framework
The built-in agent is one option. You can swap it for a major framework, LangGraph, Mastra, the Claude Agent SDK, anything that speaks AG-UI over HTTP.
You serve that agent at a URL, and the app points at it instead. Nothing else in your bot changes.
Here's what changes if you use LangGraph: only the agent file. Instead of constructing a BuiltInAgent, you point an HttpAgent at your running LangGraph server:
// agent.ts
import { HttpAgent } from "@ag-ui/client";
// Before: the built-in agent
// export function makeAgent(threadId: string) {
// const agent = new BuiltInAgent({ model: "openai/gpt-5.5" });
// agent.threadId = threadId;
// return agent;
// }
// After: your LangGraph graph, served over AG-UI at AGENT_URL
export function makeAgent(threadId: string) {
const agent = new HttpAgent({ url: process.env.AGENT_URL! });
agent.threadId = threadId;
return agent;
}
Then add AGENT_URL=http://localhost:8000/agent to .env. Your LangGraph graph runs as its own process, with its own model and tools, and the app talks to it over AG-UI.
Everything else stays the same, the tools, cards, approvals, and memory are all defined on createChannel, not the agent, so they carry over untouched.
Your channel tools and approval cards get forwarded to the agent over AG-UI, and transcripts still follow the user. You wrote it once against the built-in agent, and it keeps working when you bring any other agent framework.
Take it to production
Everything above runs from your machine.
To ship it, deploy the same app as a long-running worker (a container or VM, not a serverless function), since it holds an outbound real-time connection to Intelligence.
In production, you'll also want a health check that reports ready only when the connection is truly live, and a clean shutdown that releases the session. The deploy and operate guide covers both.
Get started
Go bring your agent into Slack and Microsoft Teams.
To try the agents live in Slack and Microsoft Teams, we've set up public channels, head to copilotkit.ai/try-channels and we'll get you in.
Connect with me on GitHub, Twitter, LinkedIn. thanks for reading!
Follow CopilotKit on Twitter. If you get stuck, reach out to the team on the CopilotKit and AG-UI communities.
United States
NORTH AMERICA
Related News
Disrupting a Criminal Scam Operation
22h ago

Greatness PhaaS Adds Device Code Phishing to Bypass MFA and Steal Tokens
4h ago

Apple just revealed a clue about its September iPhone event date
4h ago

Todayβs Android app deals and freebies: Knight Bewitched, What Lies Underground, Northgard, more
4h ago
Take an extra $100 off your TechCrunch Disrupt 2026 pass: This week only!
4h ago





















