Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 39 additions & 17 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Set SLACK_*, DISCORD_*, and/or TELEGRAM_BOT_TOKEN — the bot starts an adapter
# for each platform whose secrets are present (any one, or several at once).
# Set SLACK_*, DISCORD_*, TELEGRAM_BOT_TOKEN, and/or WHATSAPP_* — the bot starts
# an adapter for each platform whose secrets are present (any one, or several at once).

# ── Slack credentials (from api.slack.com/apps → your app) ──────────────
# Set both to run on Slack; leave blank to skip Slack.
Expand All @@ -25,27 +25,43 @@ SLACK_APP_TOKEN=xapp-...
# (no public URL or webhook setup needed).
# TELEGRAM_BOT_TOKEN=

# ── Persistence (optional) ──────────────────────────────────────────────
# Optional Redis-backed durable store. Used by `pnpm demo:restart` (and any
# bot that passes `store: { adapter: createRedisStore({ url }) }`). Leave blank
# for the in-memory default. With it set, interactive actions (e.g. an approval
# card's button) survive a bot restart — see `app/demo-restart.tsx`.
# Start a local one with `docker compose up -d`.
# REDIS_URL=redis://localhost:6379

# ── Agent backend (runtime.ts) ──────────────────────────────────────────
# The AG-UI endpoint the bridge POSTs to. Default points at the local
# CopilotKit runtime started by `pnpm runtime`.
# The AG-UI endpoint the bridge POSTs to. REQUIRED — the bridge exits at
# startup if this is unset; there is no code-level fallback. The value below
# is a template pointing at the local CopilotKit runtime started by
# `pnpm runtime`; change it if you deploy the runtime elsewhere.
AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run
# Optional auth header forwarded to the agent (for a deployed runtime).
# AGENT_AUTH_HEADER=Bearer ...

# Model for the BuiltInAgent. provider/model — the runtime resolves the
# provider and reads the matching API key below. Defaults to openai/gpt-5.5.
# AGENT_MODEL=anthropic/claude-sonnet-4.5
# ── Intelligence channel mode — app/managed.ts (`pnpm channel`) ──────────
# Runs the SAME bot over the CopilotKit Intelligence Realtime Gateway instead
# of a native adapter — this process holds NO Slack tokens (Intelligence owns
# the Slack edge). The agent brain reuses AGENT_URL / AGENT_AUTH_HEADER above.
# All values come from your CopilotKit Intelligence project + channel setup.
# Gateway runner WebSocket URL (the /runner endpoint).
# INTELLIGENCE_GATEWAY_WS_URL=wss://dev.intelligence.copilotkit.ai/runner
# Project runtime API key (cpk-{projectId}_...), presented as the socket auth token.
# INTELLIGENCE_API_KEY=cpk-...
# Authoritative org/project/channel scope (from the Intelligence dashboard):
# INTELLIGENCE_ORG_ID=org_...
# INTELLIGENCE_PROJECT_ID=123
# INTELLIGENCE_CHANNEL_ID=channel_...
# The registered channel name (lowercase kebab). Defaults to "kitebot".
# INTELLIGENCE_CHANNEL_NAME=kitebot
# Optional stable runtime instance id; auto-generated (rti_...) if unset.
# INTELLIGENCE_RUNTIME_INSTANCE_ID=rti_...

# Model for the BuiltInAgent. The runtime is OpenAI-only (it uses OpenAI's
# Responses API via TanStack AI's `openaiText` adapter, needed for the
# `web_search` provider tool) — there is no multi-provider resolution.
# AGENT_MODEL only accepts an OpenAI model id, optionally prefixed with
# "openai/" (the prefix is stripped); defaults to openai/gpt-5.5.
# AGENT_MODEL=openai/gpt-5.5
OPENAI_API_KEY=sk-...
# ANTHROPIC_API_KEY=sk-ant-...
# GOOGLE_API_KEY=...
# ANTHROPIC_API_KEY / GOOGLE_API_KEY are NOT supported by this runtime today
# (runtime.ts never reads them) — do not set AGENT_MODEL to an anthropic/... or
# google/... value.

# ── Linear MCP ──────────────────────────────────────────────────────────
# The hosted Linear MCP accepts a raw API key as a bearer token (no OAuth
Expand All @@ -69,6 +85,12 @@ NOTION_TOKEN=ntn_...
NOTION_MCP_AUTH_TOKEN=choose-a-strong-shared-secret
# Override only if the sidecar runs elsewhere; default is the local sidecar.
# NOTION_MCP_URL=http://127.0.0.1:3001/mcp
# Port the `pnpm notion-mcp` sidecar listens on (scripts/start-notion-mcp.ts).
# Must agree with the port in NOTION_MCP_URL above. Defaults to 3001.
# NOTION_MCP_PORT=3001
# Notion-Version header the sidecar sends to the Notion API. Defaults to
# 2022-06-28; override only if you need a newer Notion API version.
# NOTION_VERSION=2022-06-28

# ── WhatsApp Cloud API (optional — omit to run Slack-only) ──────────────
# From your Meta App → WhatsApp → API Setup. Leave WHATSAPP_ACCESS_TOKEN blank
Expand Down
70 changes: 48 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ renders rich results right in the conversation. Think of it as having Claude in
workspace, except **open-source and self-hosted**: you own the runtime, bring your own
model, and wire it to your own tools. No per-seat pricing, no lock-in.

It's built on **[`@copilotkit/bot`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/bot)** —
It's built on **[`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels)** —
CopilotKit's open SDK for chat-platform agents (Slack first; the same code also runs on
Discord, Telegram, and WhatsApp). Clone it, point it at your model and tools, and you own
the whole stack.
Expand All @@ -14,19 +14,31 @@ the whole stack.

https://github.com/user-attachments/assets/a74fa1cb-add0-463e-a23c-aa09b95d5135

▶️ **[Watch the demo](https://github.com/user-attachments/assets/a74fa1cb-add0-463e-a23c-aa09b95d5135)** (~50s) — an OpenTag agent working a Slack thread: it renders a breakdown, a table, and a bar chart inline (**generative UI**) and files a ticket only after an **Approve** gate (**human-in-the-loop**).
▶️ **[Watch the demo](https://github.com/user-attachments/assets/a74fa1cb-add0-463e-a23c-aa09b95d5135)** (~50s) — a KiteBot agent working a Slack thread: it renders a breakdown, a table, and a bar chart inline (**generative UI**) and files a ticket only after an **Approve** gate (**human-in-the-loop**).

> **Two ways to run it:** **host it on your own** with the open-source SDK below — or skip the ops and **[sign up for the managed service →](https://go.copilotkit.ai/opentag-managed-gh)** coming soon from CopilotKit. The managed service will be part of our Enterprise Intelligence platform. You'll be able to use our cloud-hosting or enterprises can host it on their own infra.
>
> Note: the **Intelligence Gateway** mode below is part of "host it on your own" — you run that
> process yourself and bring your own CopilotKit Intelligence project. It's distinct from the
> fully-hosted **managed service** above, which is still on the waitlist.

## Quick start (self-hosted)
## Quick start

OpenTag ships inside the [CopilotKit monorepo](https://github.com/CopilotKit/CopilotKit) as a
first-class example (`examples/slack`). That's the dependable way to run it today while the
bot SDK packages finish publishing to npm. (A standalone `npm install` from this repo lights
up the moment they land — see [setup.md](./setup.md).)
OpenTag's packages are published on npm — a standalone `pnpm install` in this repo pulls in
everything you need, no monorepo required.

You'll run two processes: the **agent** (the LLM backend) and the **bot** (the Slack
connection) — and set three secrets.
You'll run two processes: the **agent backend** (`pnpm runtime`) and **the bot**. For the bot,
pick one of two modes:

- **Intelligence Gateway — recommended.** `pnpm channel` runs the bot over the CopilotKit
Intelligence Realtime Gateway. This process never holds a Slack token — Intelligence owns
the Slack edge — so there's less for you to run and secure. You still run this process
yourself and bring your own CopilotKit Intelligence project — it's not the fully-hosted
managed service described below.
- **Self-hosted.** `pnpm dev` (or `pnpm start`) runs the bot locally and talks to Slack (and
Discord/Telegram/WhatsApp) directly with your own platform tokens.

Both modes talk to the same agent backend over AG-UI.

### The packages

Expand All @@ -36,44 +48,58 @@ OpenTag is a thin layer on top of a handful of CopilotKit packages. The `pnpm in

| Package | Role |
| --- | --- |
| [`@copilotkit/bot`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/bot) | The platform-agnostic bot engine — threading, tool calls, the human-in-the-loop gate. |
| [`@copilotkit/channels`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels) | The platform-agnostic bot engine — threading, tool calls, the human-in-the-loop gate. |
| [`@copilotkit/runtime`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/runtime) | The AG-UI agent backend that runs your LLM and tools. |
| [`@copilotkit/bot-ui`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/bot-ui) | Cross-platform JSX for rich messages (Block Kit on Slack, Components V2 on Discord, HTML on Telegram). |
| [`@copilotkit/bot-slack`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/bot-slack) | The Slack adapter — or swap it for the platform you're targeting (below). |
| [`@copilotkit/channels-ui`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-ui) | Cross-platform JSX for rich messages (Block Kit on Slack, Components V2 on Discord, HTML on Telegram). |
| [`@copilotkit/channels-slack`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-slack) | The Slack adapter — or swap it for the platform you're targeting (below). |

**Optional** — add only what you use:

| Package | When you need it |
| --- | --- |
| [`@copilotkit/bot-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/bot-discord) · [`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/bot-telegram) · [`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/bot-whatsapp) | Running on a platform other than Slack — one adapter per platform. |
| [`@copilotkit/bot-store-redis`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/bot-store-redis) | Durable thread persistence across restarts (defaults to in-memory without it). |
| [`@copilotkit/channels-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-discord) · [`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-telegram) · [`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-whatsapp) | Running on a platform other than Slack — one adapter per platform. |
| [`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence) | Runs the bot over the CopilotKit Intelligence Realtime Gateway instead of holding platform tokens — see `app/managed.ts`. |

**1. Create a Slack app.** At [api.slack.com/apps](https://api.slack.com/apps?new_app=1) →
*From a manifest* → paste [`slack-app-manifest.yaml`](./slack-app-manifest.yaml). Install it,
then grab the **Bot User OAuth Token** (`xoxb-…`) and an **App-Level Token** (`xapp-…`, with the
`connections:write` scope). Step-by-step in [setup.md](./setup.md#1-create-a-slack-app).
`connections:write` scope) — needed for self-hosted mode, or to register the app with your
CopilotKit Intelligence project for Intelligence mode. Step-by-step in
[setup.md](./setup.md#1-create-a-slack-app).

**2. Set three secrets** in `.env` (`cp .env.example .env`):
**2. Set your secrets** in `.env` (`cp .env.example .env`):

```bash
OPENAI_API_KEY=sk-... # the agent runs on OpenAI's Responses API (required for web search)

# Self-hosted mode:
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY — bring your own model

# Intelligence Gateway mode — full list in .env.example:
INTELLIGENCE_GATEWAY_WS_URL=wss://...
INTELLIGENCE_API_KEY=cpk-...
INTELLIGENCE_ORG_ID=org_...
INTELLIGENCE_PROJECT_ID=...
INTELLIGENCE_CHANNEL_ID=channel_...
```

**3. Run it** from the CopilotKit monorepo root:
**3. Run it:**

```bash
pnpm install
pnpm --filter slack-example runtime # the agent backend, on :8200
pnpm --filter slack-example dev # the bot
pnpm runtime # the agent backend, on :8200

pnpm channel # recommended — the bot over the Intelligence Gateway
# or
pnpm dev # alternative — the bot, self-hosted
```

**4. Talk to it.** @mention the bot in any channel thread:

> @OpenTag summarize this thread and file it as a bug
> @KiteBot summarize this thread and file it as a bug

That's the whole loop. To wire up Linear, Notion, inline charts, Redis persistence, or to run
That's the whole loop. To wire up Linear, Notion, inline charts, or to run
on Discord / Telegram / WhatsApp, see **[setup.md](./setup.md)**.

We won't lie to you, though. Setting up hosting for chat agents is not easy. To skip all of that heartache, go [join the waitlist](https://go.copilotkit.ai/opentag-managed-gh) for the CopilotKit managed service as part of our Intelligence platform, both cloud-hosted or self-hosted.
Expand Down
65 changes: 61 additions & 4 deletions app/commands/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, it, expect, vi } from "vitest";
import { renderToIR } from "@copilotkit/bot-ui";
import type { BotNode } from "@copilotkit/bot-ui";
import { renderToIR } from "@copilotkit/channels-ui";
import type { BotNode } from "@copilotkit/channels-ui";
import { appCommands } from "../index.js";
import type { CommandContext } from "@copilotkit/bot";
import type { CommandContext } from "@copilotkit/channels";

function tags(node: BotNode | unknown, acc: string[] = []): string[] {
if (!node || typeof node !== "object") return acc;
Expand Down Expand Up @@ -74,6 +74,27 @@ describe("example slash commands", () => {
expect(thread.post).toHaveBeenCalledTimes(1);
});

it("/agent logs and posts an apology when runAgent rejects", async () => {
const thread = fakeThread();
thread.runAgent.mockRejectedValueOnce(new Error("backend unavailable"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});

await byName("agent").handler(
ctx({
command: "agent",
text: "why is prod down",
thread: thread as never,
}),
);

expect(consoleError).toHaveBeenCalled();
expect(thread.post).toHaveBeenCalledWith(
expect.stringMatching(/sorry.*error/i),
);

consoleError.mockRestore();
});

it("/triage runs the agent with a triage prompt", async () => {
const thread = fakeThread();
await byName("triage").handler(
Expand All @@ -85,7 +106,23 @@ describe("example slash commands", () => {
);
});

it("/preview posts an ephemeral draft and reports the native path", async () => {
it("/triage with text passes the note through in the triage prompt", async () => {
const thread = fakeThread();
await byName("triage").handler(
ctx({
command: "triage",
text: "checkout is failing",
thread: thread as never,
}),
);
expect(thread.runAgent).toHaveBeenCalledTimes(1);
expect(thread.runAgent.mock.calls[0]![0]).toMatchObject({
prompt:
"Triage this and propose Linear issues to file: checkout is failing",
});
});

it("/preview posts an ephemeral draft on the native (non-fallback) path", async () => {
const preview = appCommands.find((c) => c.name === "preview")!;
expect(preview).toBeDefined();
const postEphemeral = vi
Expand All @@ -104,6 +141,8 @@ describe("example slash commands", () => {
const [user, , opts] = postEphemeral.mock.calls[0]!;
expect(user).toEqual({ id: "U1", name: "Ada" });
expect(opts).toEqual({ fallbackToDM: true });
// Native path (ok, no fallback) is silent — no follow-up narration post.
expect(post).not.toHaveBeenCalled();
});

it("/preview asks for a title when none is given", async () => {
Expand All @@ -122,6 +161,24 @@ describe("example slash commands", () => {
expect(postEphemeral).not.toHaveBeenCalled();
});

it("/preview with no user says it can't tell who you are", async () => {
const preview = byName("preview");
const postEphemeral = vi.fn();
const post = vi.fn().mockResolvedValue({ id: "1" });
await preview.handler({
thread: { postEphemeral, post } as never,
command: "preview",
text: "Login broken",
options: {},
user: undefined,
platform: "slack",
} as never);
expect(postEphemeral).not.toHaveBeenCalled();
expect(post).toHaveBeenCalledWith(
expect.stringMatching(/couldn.t tell who you are/i),
);
});

it("/file-issue opens the rich modal on Slack", async () => {
const cmd = appCommands.find((c) => c.name === "file-issue")!;
expect(cmd).toBeDefined();
Expand Down
35 changes: 30 additions & 5 deletions app/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,37 @@
* surfaces with native structured args (e.g. Discord). The `options` schema
* is optional and used there for registration/typing.
*/
import { defineBotCommand } from "@copilotkit/bot";
import type { BotCommand } from "@copilotkit/bot";
import { defineBotCommand } from "@copilotkit/channels";
import type { BotCommand } from "@copilotkit/channels";
import type { Thread as BotThread } from "@copilotkit/channels-ui";
import { senderContext } from "../sender-context.js";
import { IssueCard } from "../components/index.js";
import { FileIssueModal } from "../modals/file-issue.js";

/**
* `thread.runAgent` can reject (backend failure, network error, etc). Unlike
* `onMention` (which wraps its own call), the slash-command handlers below
* called it bare, so a failure only surfaced as an unhandledRejection and the
* user saw nothing. This wraps the call so we always log and tell the user
* something went wrong instead of going silent.
*/
async function runAgentSafely(
commandName: string,
thread: Pick<BotThread, "runAgent" | "post">,
input: Parameters<BotThread["runAgent"]>[0],
): Promise<void> {
try {
await thread.runAgent(input);
} catch (err) {
console.error(`[command] ${commandName} run failed`, err);
await thread
.post("Sorry — I hit an error handling that. Please try again.")
.catch((postErr: unknown) =>
console.error(`[command] ${commandName} failed to post error`, postErr),
);
}
}

export const appCommands: BotCommand[] = [
// `/agent <text>` — a mention-free entry point. (Previously hardcoded in the
// adapter; now an ordinary, app-owned command.) Runs the agent with the
Expand All @@ -30,7 +55,7 @@ export const appCommands: BotCommand[] = [
await thread.post("Usage: `/agent <your question>`");
return;
}
await thread.runAgent({
await runAgentSafely("agent", thread, {
prompt: text,
context: senderContext(user, thread.platform),
});
Expand All @@ -47,7 +72,7 @@ export const appCommands: BotCommand[] = [
const prompt = text
? `Triage this and propose Linear issues to file: ${text}`
: "Triage the current conversation: summarize it and propose Linear issues to file.";
await thread.runAgent({
await runAgentSafely("triage", thread, {
prompt,
context: senderContext(user, thread.platform),
});
Expand Down Expand Up @@ -114,7 +139,7 @@ export const appCommands: BotCommand[] = [
"Modals aren't supported here — let's do it in chat instead. " +
"Tell me the issue title and a short description and I'll file it.",
);
await thread.runAgent({
await runAgentSafely("file-issue", thread, {
prompt:
"The user wants to file a Linear issue but this platform has no modal form. " +
"Ask them for a title and description, then (after the usual confirm) file it.",
Expand Down
Loading