cd /news/ai-agents/channels-sdk-how-to-bring-your-agent… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-86507] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=↑ positive

Channels SDK: How to bring Your Agent to Any Channel (Slack, Microsoft Teams)

CopilotKit has released the Channels SDK, an open-source TypeScript library that brings any AG-UI agent to messaging platforms like Slack, Microsoft Teams, Discord, and Telegram from a single codebase. The SDK includes a persistence layer for memory and context, and supports managed production deployments via CopilotKit Intelligence, eliminating the need to expose public URLs. The project demonstrates integrating an agent into Slack with native rendering through JSX components and MCP tool connections.

read15 min views1 publishedAug 4, 2026

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.

A Slack bot running on your own agent, that can:

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.

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.

Getting an agent to your channels usually involves:

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.

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>

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, ...

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:

xoxb-…

), from 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.

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.

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.

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.

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

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:

createChannel

declares the bot, which channel, which agent, and store

.

onMessage

runs 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.

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 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.

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.

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.

ntn_…

).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.

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, s, and continues only once approved. Read more on the Interactive messages docs.

You can also add more context using the what

field.

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.

The agent remembers in two ways.

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.

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.

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.

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.

── more in #ai-agents 4 stories Β· sorted by recency
── more on @copilotkit 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/channels-sdk-how-to-…] indexed:0 read:15min 2026-08-04 Β· β€”