examples/slack/README.md
A runnable demo for @copilotkit/bot-slack,
@copilotkit/bot-discord,
@copilotkit/bot-telegram, and
@copilotkit/bot-whatsapp: an on-call triage bot
that turns incident chatter into tracked work. It's built with
@copilotkit/bot (the platform-agnostic bot core), one or
more platform adapters, and @copilotkit/bot-ui (a
cross-platform JSX vocabulary for rich messages).
One app, any platform — or all at once. createBot takes an array of
adapters; app/index.ts includes the Slack adapter when SLACK_* secrets are
present, the Discord adapter when DISCORD_* are present, the Telegram adapter
when TELEGRAM_BOT_TOKEN is present, and the WhatsApp adapter when WHATSAPP_*
are present. Everything else in app/ (tools,
components, the confirm_write HITL gate, chart/diagram/table rendering) is
platform-agnostic and shared verbatim — set the secrets for whichever
platform(s) you want and run the same process. It connects to Linear and
Notion over MCP and can:
Every write goes through a human-in-the-loop confirm_write gate: the
agent must call that tool and wait for a Create/Cancel click before it
performs any Linear/Notion write.
Slack / Discord / Telegram ──@mention──▶ bot (app/) ──AG-UI──▶ runtime (runtime.ts)
│ BuiltInAgent (LLM)
├── Linear MCP (hosted)
└── Notion MCP (sidecar)
app/ — the platform-agnostic bot: createBot + whichever of the
slack() / discord() / telegram() adapters have secrets, the
read_thread / render_chart / render_diagram / render_table tools,
the issue_card / issue_list / page_list render-tools, the
confirm_write HITL gate, and the bot's context. The components emit a
cross-platform JSX IR that each adapter renders natively. This is the
directory you'd copy to start your own bot.runtime.ts — the agent backend: a single CopilotKit BuiltInAgent
(LLM + Linear/Notion MCP), served over AG-UI. No Python, no LangGraph.e2e/ — live test harnesses. The Slack harness (run.ts /
restart-recovery.ts, pnpm e2e) is legacy/WIP — see Tests;
the Telegram harness (telegram-run.ts, pnpm e2e:telegram) is a
manual-trigger smoke test — see e2e/TELEGRAM-README.md.app/index.ts)The core shape is createBot + one or more adapters, an onMention handler,
and start(). The snippet below is an abridged, single-platform sketch —
the real app/index.ts builds the adapter list from whichever secrets are
present (Slack, Discord, and/or Telegram) and adds graceful shutdown; read the
file for the full multi-platform wiring:
import { createBot } from "@copilotkit/bot";
import {
slack,
defaultSlackTools,
defaultSlackContext,
SanitizingHttpAgent,
} from "@copilotkit/bot-slack";
import { appTools } from "./tools/index.js";
import { appContext } from "./context/app-context.js";
const bot = createBot({
adapters: [
slack({
botToken: process.env.SLACK_BOT_TOKEN!,
appToken: process.env.SLACK_APP_TOKEN!,
}),
],
// One AG-UI agent per conversation, pointed at the runtime.
agent: (threadId) => {
const a = new SanitizingHttpAgent({ url: process.env.AGENT_URL! });
a.threadId = threadId;
return a;
},
// defaultSlackTools ships universal-Slack tools (e.g. lookup_slack_user
// for @-mentions); appTools adds this bot's tools. defaultSlackContext
// ships tagging/mrkdwn/thread-model guidance; appContext adds identity +
// triage policy.
tools: [...defaultSlackTools, ...appTools],
context: [...defaultSlackContext, ...appContext],
});
// One handler covers @-mentions, replies in threads the bot owns, and DMs.
// senderContext names the requesting user so the agent acts "as" them.
bot.onMention(async ({ thread, message }) => {
await thread.runAgent({ context: senderContext(message.user) });
});
await bot.start();
app/tools/index.ts)The bot's tools are plain BotTools, collected into appTools and spread
into createBot({ tools }). Each handler receives the generic
BotToolContext ({ thread, message?, user?, signal?, platform }) the
adapter supplies at call time; tools reach platform power (post, postFile,
thread.getMessages(), …) via the thread methods:
read_thread — fetches the messages in the current conversation thread
so the agent can summarize/act on a real conversation (e.g. "write this
thread up as a postmortem") instead of inventing content.render_chart — the agent emits a Chart.js config; rendered to a PNG
locally in a headless browser (reusing the Playwright dep) and posted
inline.render_diagram — the agent emits Mermaid; rendered to a PNG the same
way.render_table — the agent emits columns + rows; rendered natively per
platform (a Slack Table block, otherwise a monospace fallback).Rich messages are authored as JSX components over the @copilotkit/bot-ui
vocabulary (<Message>, <Header>, <Section>, <Context>, <Actions>,
<Button>, …). Each component (IssueCard, IssueList, PageList,
ConfirmWrite) is a plain function whose zod prop schema doubles as a tool
input schema. Each adapter renders the same IR natively (Block Kit on Slack,
Components V2 on Discord, HTML on Telegram).
The agent renders them through render-tools — BotTools that wrap a
component and post it. The agent calls the tool; the handler renders the
component and posts it to the thread:
export const issueCardTool: BotTool<typeof issueCardSchema> = {
name: "issue_card",
description: "Render ONE Linear issue as a rich card …",
parameters: issueCardSchema,
async handler(props, { thread }) {
await thread.post(<IssueCard {...props} />);
return JSON.stringify({ ok: true, rendered: "issue_card" });
},
};
The three render-tools are issue_card (a single Linear issue, or one
you just created with justCreated: true), issue_list (several Linear
issues), and page_list (Notion pages). The system prompt steers the
agent to present results with these instead of prose.
confirm_writeHITL is a blocking frontend tool. Before any Linear/Notion write the
agent must call confirm_write, whose handler posts a Create/Cancel card
and blocks until the user clicks — then resolves to the clicked button's
value, { confirmed: boolean }. The agent only performs the write when it
gets back { confirmed: true }.
export const confirmWriteTool: BotTool<typeof confirmWriteSchema> = {
name: "confirm_write",
description:
"Ask the user to approve a write before you perform it … returns {confirmed}.",
parameters: confirmWriteSchema,
async handler({ action, detail }, { thread }) {
const choice = await thread.awaitChoice(
<ConfirmWrite action={action} detail={detail} />,
);
return JSON.stringify(choice ?? { confirmed: false });
},
};
<ConfirmWrite> is a JSX card whose Create/Cancel <Button>s each carry a
value ({ confirmed: true|false }) and an inline onClick that updates
the card in place to an approved/declined state — so the picker reflects the
decision the moment it's clicked. (On Telegram the value can't ride in the
64-byte callback_data, so the core recovers it from the rendered button.)
app/commands/)Two app-owned slash commands, registered via createBot({ commands }):
/agent <text> — a mention-free entry point; runs the agent with the
command text as the prompt./triage [note] — summarizes the conversation and proposes Linear
issues to file.defineBotCommand({
name: "agent",
description: "Ask the triage agent anything (no @mention needed).",
async handler({ thread, text, user }) {
if (!text) return void thread.post("Usage: `/agent <your question>`");
await thread.runAgent({ prompt: text, context: senderContext(user) });
},
});
The args arrive as ctx.text; runAgent({ prompt }) injects them as the
user message (a slash command's text is never posted to the channel, so it
isn't in the history the agent reconstructs).
Slack setup: each command must also be declared in your Slack app under Slash Commands (add
/agentand/triage) — Slack won't deliver an unregistered command, even over Socket Mode. Discord and Telegram register their commands up front via the adapter.
runtime.ts)A single CopilotKit BuiltInAgent (LLM + MCP) served over AG-UI by a
CopilotSseRuntime. It connects to Linear (hosted MCP, raw API key as
bearer token) and Notion (the official MCP server run as a local
Streamable-HTTP sidecar), discovering the available list/search/create tools
from each server at runtime. A server is only wired up when its credentials
are present, so the bot runs Linear-only, Notion-only, or both. The default
model is openai/gpt-5.5 (override with AGENT_MODEL).
Pieces: the chat-platform app(s) (Slack, Discord, and/or Telegram, created
once), the optional Notion MCP sidecar, the agent (runtime.ts), and
the bot (app/). Set up whichever platform(s) you want — the bot starts an
adapter for each one whose secrets are present (so you can run any one, or
several from one process).
This example runs from the monorepo. The Telegram work ships an unpublished package (
@copilotkit/bot-telegram) and depends on a fix in the core (@copilotkit/bot), so all@copilotkit/*deps areworkspace:*and the example runs against local source:pnpm --filter slack-example <script>. Once those versions publish, switch the deps to published ranges for a standalone build.
SLACK_* to enable Slack)slack-app-manifest.yaml.xoxb-
bot token (SLACK_BOT_TOKEN).connections:write → copy the xapp- app token (SLACK_APP_TOKEN).DISCORD_* to enable Discord)DISCORD_BOT_TOKEN); under Privileged Gateway
Intents enable both Message Content and Server Members — both
are required or the Gateway login is rejected.DISCORD_APP_ID).bot + applications.commands,
permissions Send Messages / Read Message History / Use Slash Commands /
Embed Links → open the URL to add it to your server. Optionally set
DISCORD_GUILD_ID (your server id) so slash commands register instantly
during dev.TELEGRAM_BOT_TOKEN to enable Telegram)/newbot → follow the prompts (name +
a username ending in bot) → copy the HTTP API token (TELEGRAM_BOT_TOKEN)./agent and /triage via setMyCommands on start
(no manual BotFather /setcommands step). For group use, /setprivacy →
Disable if you want it to see non-mention messages.cp .env.example .env
# Fill in (set SLACK_*, DISCORD_*, and/or TELEGRAM_BOT_TOKEN — whichever you want):
# SLACK_BOT_TOKEN / SLACK_APP_TOKEN (to run on Slack)
# DISCORD_BOT_TOKEN / DISCORD_APP_ID (to run on Discord; DISCORD_GUILD_ID optional)
# TELEGRAM_BOT_TOKEN (to run on Telegram)
# OPENAI_API_KEY (or ANTHROPIC_API_KEY / GOOGLE_API_KEY + AGENT_MODEL)
# LINEAR_API_KEY (linear.app → Settings → API → Personal API keys)
# NOTION_TOKEN (notion.so → Settings → Connections → integrations)
# NOTION_MCP_AUTH_TOKEN (any strong string; shared between the sidecar and the agent)
Linear and Notion are independent — set only the ones you want; the agent wires up whichever credentials are present.
The agent talks to Notion through the official MCP server, run locally as a Streamable-HTTP sidecar:
pnpm install # from the repo root
pnpm --filter slack-example notion-mcp # serves http://127.0.0.1:3001/mcp
Linear needs no sidecar — its hosted MCP accepts the API key directly.
pnpm --filter slack-example runtime # CopilotKit runtime on :8200, agent "triage"
Exposes http://localhost:8200/api/copilotkit/agent/triage/run — the
default AGENT_URL.
pnpm --filter slack-example dev # tsx watch app/index.ts
@mention the bot in a channel (Slack/Discord) or DM it / @mention it in a group (Telegram):
@CopilotKit Triage what are the open CPK issues this cycle?
@CopilotKit Triage file this thread as a bug in CPK
@CopilotKit Triage find the runbook for our last auth outage
@CopilotKit Triage write this thread up as a Notion postmortem
The onMention handler forwards the requesting user (resolved to name +
email where the platform exposes it) to the agent each turn via
senderContext(message.user), so the bot acts on behalf of whoever's asking:
"my issues" is scoped to you, and issues it files are assigned to you. On Slack
this needs the users:read.email scope (already in the manifest — reinstall
the app once after adding it).
Caveat: a single API key can't forge Linear's creator, so created issues
are authored by the bot and assigned to the requester. True per-user
attribution (and reliable Notion personalization) needs per-user OAuth.
Upload a file and the bot analyzes it: images and PDFs go straight to the
model, and CSV/JSON/text are decoded and handed over as text. The adapter is
transport-only — it downloads the upload and delivers it to the agent as
multimodal content; the app (the render_* tools above) decides what to
do.
PDFs and images need a vision/document-capable model. The default
openai/gpt-5.5reads both natively through this path, as do recent Claude (anthropic/claude-sonnet-4-6) and Gemini (google/gemini-2.5-*) models. An older text-only model will ignore the attached document.
Try it: drop a CSV and say "chart revenue by month", "diagram this incident flow", or "show the incidents as a table". The chart/diagram renderers need a Chromium binary:
npx playwright install chromium
Notes: the chart/diagram libraries load from a CDN into the local browser
(override CHART_JS_URL / MERMAID_URL); your data is rendered locally and
never sent to a rendering service.
There's nothing local-only here: the bot and the runtime are plain Node
processes, and every connection is env-driven. Deploy the runtime and bot,
set the same env vars, and (for Notion) run the
@notionhq/notion-mcp-server sidecar alongside the runtime with
NOTION_MCP_URL pointed at it.
This example consumes the @copilotkit/* packages via the workspace:*
protocol, so it always builds from the in-repo source — not the npm
registry. That decouples the deploy from publishing: a change to
packages/** redeploys with the new code immediately, and npm publish is an
independent, manual step (no "release first, then bump the example" dance).
Because it's a workspace member, the deploy must run from the repo root so
the workspace and packages/** are visible. On Railway (or any host), set:
| Setting | Value |
|---|---|
| Root Directory | repo root (/) |
| Build Command | pnpm install && pnpm --filter slack-example build |
| Start Command | pnpm --filter slack-example start (bot) — a second service runs the runtime: pnpm --filter slack-example run runtime |
| Watch Paths | packages/**, examples/slack/**, pnpm-lock.yaml, package.json |
pnpm --filter slack-example build builds the workspace libs the example
imports (@copilotkit/bot-slack / -discord / -telegram / runtime) and
everything they depend on, via the Nx project graph — so tsx runs against
fresh dist. The Watch Paths are what makes a packages/**-only change
trigger a redeploy (the example's own files no longer need to change to provoke
one).
Copying this example out of the monorepo? Replace the
workspace:*ranges inpackage.jsonwith the published versions (e.g.@copilotkit/bot-slack: ^0.0.3) —workspace:*only resolves inside this monorepo.
Slack and Discord are outbound (Socket Mode / gateway) and need no public
ingress. WhatsApp is different: it adds an inbound webhook HTTP server on
$PORT, so the bot service needs a public URL. To enable it on the deployed
bot service (Railway):
$PORT, which the WhatsApp adapter listens on.WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_APP_SECRET,
WHATSAPP_VERIFY_TOKEN on the bot service (use a System User token — the
temporary one expires in 24h). The runtime service is unchanged.https://<bot-domain>/webhook, Verify Token = WHATSAPP_VERIFY_TOKEN,
subscribe to the messages field.Health check: GET https://<bot-domain>/ returns ok. Chart/diagram tools use
the same headless browser the Slack/Discord paths already run; their PNGs go
out as WhatsApp images via the media upload.
pnpm --filter slack-example test # unit tests (read_thread, render tools, components, confirm_write)
Note: the live-Slack e2e harness (
pnpm e2e/pnpm e2e:restart) is being migrated to the newcreateBotAPI — it still targets the old bridge and the obsolete button-value resume path, so it does not run against this example as-is. The Telegram harness (pnpm e2e:telegram) is a working manual-trigger smoke test — seee2e/TELEGRAM-README.md.