Back to Copilotkit

slack-example — on-call triage assistant

examples/slack/README.md

1.60.112.3 KB
Original Source

slack-example — on-call triage assistant

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:

  • Query Linear"what's open in CPK this cycle?" → renders issues as a rich Block Kit card.
  • File a Linear issue"file this thread as a bug" → drafts the issue, asks you to confirm, then creates it.
  • Find Notion pages"find the runbook for the auth outage" → renders matching pages with links.
  • Write a postmortem"write this thread up as a Notion doc" → reads the thread, summarizes, confirms, then creates the page.

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.

How it fits together

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.

The bot (app/index.ts)

The whole bot is createBot + the Slack adapter, one onMention handler, and start():

ts
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();

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

UI as JSX components

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-toolsBotTools that wrap a component and post it. The agent calls the tool; the handler renders the component and posts it to the thread:

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

Human-in-the-loop: confirm_write

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

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

Slash commands (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.
ts
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 /agent and /triage) — Slack won't deliver an unregistered command, even over Socket Mode. The command name there must match the registered name.

The agent (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).

Local run

Four pieces: the Slack app (created once), the optional Notion MCP sidecar, the agent (runtime.ts), and the bot (app/).

1. Slack app

  • https://api.slack.com/apps?new_app=1From a manifest → paste slack-app-manifest.yaml.
  • OAuth & PermissionsInstall to Workspace → copy the xoxb- bot token.
  • Basic Information → App-Level Tokens → generate one with connections:write → copy the xapp- app token.

2. Credentials

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

3. Notion MCP sidecar (only if using Notion)

The agent talks to Notion through the official MCP server, run locally as a Streamable-HTTP sidecar:

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

4. Agent

bash
pnpm runtime        # CopilotKit runtime on :8200, agent "triage"

Exposes http://localhost:8200/api/copilotkit/agent/triage/run — the default AGENT_URL.

5. Bot

bash
pnpm dev            # tsx watch app/index.ts

6. Try it

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

Per-user identity

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.

Files → charts, diagrams & tables

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.5 reads 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:

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

Deploying

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.

Tests

bash
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 new createBot API — it still targets the old bridge and the obsolete button-value resume path, so it does not run against this example as-is.