examples/slack/README.md
A runnable demo for @copilotkit/bot-slack: a
Slack bot that turns incident chatter into tracked work. It's built with
@copilotkit/bot (the platform-agnostic bot core),
the Slack adapter, and @copilotkit/bot-ui (a
cross-platform JSX vocabulary for rich messages). 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 ──@mention──▶ bot (app/) ──AG-UI──▶ runtime (runtime.ts)
│ BuiltInAgent (LLM)
├── Linear MCP (hosted)
└── Notion MCP (sidecar)
app/ — the Slack-side bot: createBot + the slack() adapter, 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. 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/ — a live-Slack test harness (sends real messages to a test
channel). Legacy/WIP — see Tests.app/index.ts)The whole bot is createBot + the Slack adapter, one onMention handler,
and start():
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 Slack 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 Slack 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 Slack 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; posted as a native
Slack Table block (no browser needed), with 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.
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 Block Kit 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.
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. The command name there must match the registeredname.
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).
Four pieces: the Slack app (created once), the optional Notion MCP
sidecar, the agent (runtime.ts), and the bot (app/).
slack-app-manifest.yaml.xoxb-
bot token.connections:write → copy the xapp- app token.cp .env.example .env
# Fill in:
# SLACK_BOT_TOKEN / SLACK_APP_TOKEN
# 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 notion-mcp # serves http://127.0.0.1:3001/mcp
Linear needs no sidecar — its hosted MCP accepts the API key directly.
pnpm runtime # CopilotKit runtime on :8200, agent "triage"
Exposes http://localhost:8200/api/copilotkit/agent/triage/run — the
default AGENT_URL.
pnpm dev # tsx watch app/index.ts
Invite the bot to a channel and @mention it:
@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 Slack user (resolved to
name + email) 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. 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.
pnpm 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.