Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
18 changes: 11 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@ 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
# ── Managed mode (Intelligence-hosted bot — `pnpm managed`) ─────────────
# Only for `app/managed.ts`. Here Slack credentials live in Intelligence, NOT
# in this app: Intelligence receives the Slack event and delivers it to this
# process over HTTP. `intelligenceAdapter()` reads these from the environment.
# Base URL of the running Intelligence app-api.
# COPILOTKIT_INTELLIGENCE_URL=http://localhost:7050
# Project runtime API key (cpk-…) minted in the Intelligence UI: project → API keys.
# COPILOTKIT_API_KEY=cpk-...
# Bot name — must match the bot created in the Intelligence UI. Lowercase
# letters/digits only (intersection of the SDK and Intelligence name rules).
# MANAGED_BOT_NAME=opentagbot

# ── Agent backend (runtime.ts) ──────────────────────────────────────────
# The AG-UI endpoint the bridge POSTs to. Default points at the local
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ e2e/results
state.db
state.db-shm
state.db-wal

# Local dev/runtime process logs & bundles
.*.log
.*-bundle.mjs
7 changes: 7 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@
# correct at runtime. `legacy-peer-deps` lets `npm install` proceed past that
# advisory peer mismatch — the same thing pnpm does by default in the monorepo.
legacy-peer-deps=true

# The @copilotkit/* packages are consumed from the PR #5761 pkg.pr.new build
# (see package.json), and those tarballs cross-reference each other via URL
# (exotic) dependencies. The global `block-exotic-subdeps` hardening rejects URL
# subdeps, so allow them here while on the pkg.pr.new pin. Remove once the bot
# packages publish to npm. Scoped to this project only.
block-exotic-subdeps=false
59 changes: 59 additions & 0 deletions MANAGED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Managed mode — OpenTag on CopilotKit Intelligence

Run the same OpenTag bot, but with **CopilotKit Intelligence** as the managed
bridge: Intelligence receives the Slack event, persists it, and delivers it to
this process over HTTP; this process runs the bot's normal handlers/tools/agent
and emits a reply; Intelligence does the credentialed Slack send. **No Slack
tokens live in this app** in managed mode.

The only OpenTag code is one entry — `app/managed.ts` — which attaches a single
config-free `intelligenceAdapter()`:

```ts
const bot = createBot({ name, agent, tools, context, commands,
adapters: [intelligenceAdapter()] }); // env-driven HTTP transport to Intelligence
await bot.start();
```

## Prerequisites

1. A running **Intelligence app-api** with the hosted-bots routes (the
`codex/hosted-bots-infra-vertical-slice` branch), `FF_MANAGED_BOTS=true`. See
that repo's `local/README.md`. Default local base URL: `http://localhost:7050`.
2. In the Intelligence UI (`/o/:org/:project/bots`):
- **Create a bot** with name `opentagbot` (lowercase letters/digits — the
intersection of the SDK and Intelligence name rules).
- **Attach a Slack adapter** with a real Slack app's `botToken` + `signingSecret`.
- **Mint a runtime API key** (project → API keys); copy the `cpk-…` token once.
3. A **real Slack app** whose Event Subscriptions request URL points (via a
tunnel, e.g. `ngrok http 7050`) at
`https://<tunnel>/api/bots/adapters/slack/events`, subscribed to `app_mention`
(+ `message.im`), scopes `app_mentions:read` + `chat:write`.

## Run

```bash
# agent backend (unchanged — same as the direct path)
pnpm runtime # http://localhost:8200

# managed bot
COPILOTKIT_INTELLIGENCE_URL=http://localhost:7050 \
COPILOTKIT_API_KEY=cpk-... \
MANAGED_BOT_NAME=opentagbot \
AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run \
pnpm managed
```

`pnpm managed` heartbeats Intelligence, polls for deliveries, runs the agent per
turn, and posts the reply back through Intelligence. @-mention the bot in a Slack
channel it's in; the reply comes back in-thread.

## ⚠️ Temporary dependency pin

`package.json` pins every `@copilotkit/*` package to the **PR #5761** pkg.pr.new
build, at an **immutable commit** (`…/@copilotkit/<pkg>@0641b63`) so the resolved
build can't drift (the mutable `@5761` tag can be cached stale). pkg.pr.new does
not publish `@copilotkit/bot-store-redis`, so the Redis demo was removed on this
branch. `pnpm-workspace.yaml` sets `blockExoticSubdeps: false` because pkg.pr.new
packages cross-reference each other by URL. **Revert to published versions and
drop these workarounds once #5761 merges and releases.**
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ OpenTag is a thin layer on top of a handful of CopilotKit packages. The `pnpm in
| 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). |

**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,
Expand All @@ -73,7 +72,7 @@ pnpm --filter slack-example dev # the bot

> @OpenTag 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
12 changes: 6 additions & 6 deletions app/commands/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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 { ChannelNode } 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[] {
function tags(node: ChannelNode | unknown, acc: string[] = []): string[] {
if (!node || typeof node !== "object") return acc;
const n = node as BotNode;
const n = node as ChannelNode;
if (typeof n.type === "string") acc.push(n.type);
for (const c of (n.props?.children as BotNode[] | undefined) ?? []) {
for (const c of (n.props?.children as ChannelNode[] | undefined) ?? []) {
tags(c, acc);
}
return acc;
Expand Down
31 changes: 22 additions & 9 deletions app/commands/index.ts → app/commands/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Slash commands for this bot. Each is registered with the engine via
* `createBot({ commands })`; the Slack adapter forwards every `/command` it
* `createChannel({ commands })`; the Slack adapter forwards every `/command` it
* receives and the engine routes by name (ignoring unregistered ones).
*
* NOTE: a slash command only fires if it's also declared in the Slack app
Expand All @@ -11,18 +11,18 @@
* 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 { defineChannelCommand } from "@copilotkit/channels";
import type { ChannelCommand } from "@copilotkit/channels";
import { senderContext } from "../sender-context.js";
import { IssueCard } from "../components/index.js";
import { IssueCard, JokeCard } from "../components/index.js";
import { FileIssueModal } from "../modals/file-issue.js";

export const appCommands: BotCommand[] = [
export const appCommands: ChannelCommand[] = [
// `/agent <text>` — a mention-free entry point. (Previously hardcoded in the
// adapter; now an ordinary, app-owned command.) Runs the agent with the
// command text as the user prompt, since slash-command args are never
// posted to the channel for the agent to read from history.
defineBotCommand({
defineChannelCommand({
name: "agent",
description: "Ask the triage agent anything (no @mention needed).",
async handler({ thread, text, user }) {
Expand All @@ -39,7 +39,7 @@ export const appCommands: BotCommand[] = [

// `/triage [note]` — summarize the current channel/thread and propose Linear
// issues to file. Demonstrates a command with its own intent.
defineBotCommand({
defineChannelCommand({
name: "triage",
description:
"Summarize the conversation and propose Linear issues to file.",
Expand All @@ -60,7 +60,7 @@ export const appCommands: BotCommand[] = [
// a native only-you message; Discord and Telegram have no ephemeral surface, so
// `fallbackToDM: true` sends it as a direct message instead. We narrate which
// path was taken so the degradation is visible, never silent.
defineBotCommand({
defineChannelCommand({
name: "preview",
description: "Privately preview the issue I'd file (only you see it).",
async handler({ thread, text, user, platform }) {
Expand Down Expand Up @@ -105,7 +105,7 @@ export const appCommands: BotCommand[] = [
// dropdowns/radio drop and defaults apply (see FileIssueModal).
// - Telegram→ no modal trigger at all (`ctx.openModal` is undefined), so we
// say so and continue the same job conversationally via the agent.
defineBotCommand({
defineChannelCommand({
name: "file-issue",
description: "Open a form to file a Linear issue.",
async handler({ thread, openModal, platform, user }) {
Expand All @@ -132,4 +132,17 @@ export const appCommands: BotCommand[] = [
}
},
}),

// `/joke` — reaction demo. Posts a card carrying its OWN `<Message
// onReaction>` handler: react 👍 on the card and the per-message handler
// tells a joke. (Reacting 🔄 on any message uses the global handler in
// app/managed.ts.) Posted as a component element (`<JokeCard/>`) so the
// handler persists durably and survives a managed-loop restart.
defineChannelCommand({
name: "joke",
description: "Post a card — react 👍 on it and it tells a joke.",
async handler({ thread }) {
await thread.post(<JokeCard />);
},
}),
];
8 changes: 4 additions & 4 deletions app/components/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Block Kit parity tests for the JSX render components. Each component is a
* `@copilotkit/bot-ui` `ComponentFn`; we assert the full
* `@copilotkit/channels-ui` `ComponentFn`; we assert the full
* `renderSlackMessage(renderToIR(<… />))` output — both the `blocks` and the
* attachment `accent` — against the legacy `defineSlackComponent` shapes.
*
Expand All @@ -15,9 +15,9 @@
* so they render identically on both Slack and Telegram.
*/
import { describe, it, expect } from "vitest";
import { renderToIR } from "@copilotkit/bot-ui";
import { renderSlackMessage } from "@copilotkit/bot-slack";
import { renderTelegram } from "@copilotkit/bot-telegram";
import { renderToIR } from "@copilotkit/channels-ui";
import { renderSlackMessage } from "@copilotkit/channels-slack";
import { renderTelegram } from "@copilotkit/channels-telegram";
import { IssueList } from "../issue-list.js";
import { IssueCard } from "../issue-card.js";
import { PageList } from "../page-list.js";
Expand Down
4 changes: 3 additions & 1 deletion app/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* App-specific render components — agent-renderable Block Kit cards authored
* with the `@copilotkit/bot-ui` JSX vocabulary.
* with the `@copilotkit/channels-ui` JSX vocabulary.
*
* Each component is a plain `ComponentFn` returning a `<Message>` tree; its
* exported zod prop schema doubles as the render-tool input schema. Render a
Expand All @@ -14,3 +14,5 @@ export type { IssueListProps } from "./issue-list.js";

export { PageList, pageListSchema } from "./page-list.js";
export type { PageListProps } from "./page-list.js";

export { JokeCard, pickJoke } from "./joke-card.js";
8 changes: 4 additions & 4 deletions app/components/issue-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Use it for one issue — when the user asks about a specific issue, or
* right after creating one (it doubles as the "filed!" confirmation).
*
* Authored with the `@copilotkit/bot-ui` JSX vocabulary; the Block Kit
* Authored with the `@copilotkit/channels-ui` JSX vocabulary; the Block Kit
* shapes are produced by `renderSlackMessage(renderToIR(<IssueCard .../>))`.
*/
import { z } from "zod";
Expand All @@ -19,8 +19,8 @@ import {
Header,
Message,
Section,
} from "@copilotkit/bot-ui";
import type { BotNode } from "@copilotkit/bot-ui";
} from "@copilotkit/channels-ui";
import type { ChannelNode } from "@copilotkit/channels-ui";
import { accentForIssue, priorityGlyph, stateGlyph } from "./_status.js";

export const issueCardSchema = z.object({
Expand Down Expand Up @@ -51,7 +51,7 @@ export const issueCardSchema = z.object({
export type IssueCardProps = z.infer<typeof issueCardSchema>;

/** Render ONE Linear issue as a rich Block Kit card. */
export function IssueCard(issue: IssueCardProps): BotNode {
export function IssueCard(issue: IssueCardProps): ChannelNode {
const titleText = issue.url
? `[**${issue.title}**](${issue.url})`
: `**${issue.title}**`;
Expand Down
8 changes: 4 additions & 4 deletions app/components/issue-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
* it wants shown; the Slack formatting lives here. For a single issue (or
* right after creating one) prefer `issue_card`, which shows a full grid.
*
* Authored with the `@copilotkit/bot-ui` JSX vocabulary.
* Authored with the `@copilotkit/channels-ui` JSX vocabulary.
*/
import { z } from "zod";
import { Context, Header, Message, Section } from "@copilotkit/bot-ui";
import type { BotNode } from "@copilotkit/bot-ui";
import { Context, Header, Message, Section } from "@copilotkit/channels-ui";
import type { ChannelNode } from "@copilotkit/channels-ui";
import { accentForIssues, stateGlyph } from "./_status.js";

const issueSchema = z.object({
Expand Down Expand Up @@ -59,7 +59,7 @@ const MAX = 15;
const TITLE_MAX = 70;

/** Render a list of Linear issues as a compact, fixed-size Block Kit card. */
export function IssueList({ heading, issues }: IssueListProps): BotNode {
export function IssueList({ heading, issues }: IssueListProps): ChannelNode {
const lines = issues.slice(0, MAX).map((issue: Issue) => {
const idLink = issue.url
? `[**${issue.identifier}**](${issue.url})`
Expand Down
48 changes: 48 additions & 0 deletions app/components/joke-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* `joke-card` — a reaction demo authored with the `@copilotkit/channels-ui` JSX
* vocabulary. The card carries its OWN reaction handler via `<Message
* onReaction>`: react 👍 on it and the per-message handler posts a joke.
*
* This is the managed-path proof for the JSX reaction path: the handler is
* attached to the POSTED message (not a global `bot.onReaction`), so it's
* registered/persisted under the post-time message ref and must be resolved
* when a later reaction arrives keyed by the provider ts — which app-api now
* reverse-maps (ts → ref) so the SDK can find it. Self-contained: needs no
* message text or thread history (which the managed path doesn't reconstruct).
*/
import { Context, Message, Section } from "@copilotkit/channels-ui";
import type { ChannelNode } from "@copilotkit/channels-ui";

export const JOKES = [
"Why do programmers prefer dark mode? Because light attracts bugs.",
"There are 10 kinds of people: those who understand binary and those who don't.",
"I would tell you a UDP joke, but you might not get it.",
"A SQL query walks into a bar, walks up to two tables and asks: 'Can I join you?'",
"Why did the developer go broke? He used up all his cache.",
"How many programmers to change a light bulb? None — that's a hardware problem.",
"Debugging: being the detective in a crime movie where you're also the murderer.",
];

export const pickJoke = (): string =>
JOKES[Math.floor(Math.random() * JOKES.length)]!;

/** Slack shortnames for 👍 (arrives as `event.reaction`, without colons). */
const THUMBS_UP = new Set(["+1", "thumbsup"]);

/** A card whose OWN `<Message onReaction>` tells a joke when you react 👍. */
export function JokeCard(): ChannelNode {
return (
<Message
onReaction={async (_emoji, reaction) => {
// Fire only when 👍 is ADDED (ignore removes + every other emoji).
if (!reaction.added || !THUMBS_UP.has(reaction.rawEmoji)) return;
await reaction.thread.post(`🃏 (per-message handler) ${pickJoke()}`);
}}
>
<Section>
{"React 👍 on this card and its own handler will tell you a joke."}
</Section>
<Context>{"_(🔄 on any message uses the global handler instead)_"}</Context>
</Message>
);
}
10 changes: 5 additions & 5 deletions app/components/page-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
* The agent searches Notion via MCP and passes the pages it wants to
* surface; the Slack formatting lives here.
*
* Authored with the `@copilotkit/bot-ui` JSX vocabulary.
* Authored with the `@copilotkit/channels-ui` JSX vocabulary.
*/
import { z } from "zod";
import { Context, Divider, Header, Message, Section } from "@copilotkit/bot-ui";
import type { BotNode } from "@copilotkit/bot-ui";
import { Context, Divider, Header, Message, Section } from "@copilotkit/channels-ui";
import type { ChannelNode } from "@copilotkit/channels-ui";
import { ACCENT } from "./_status.js";

const pageSchema = z.object({
Expand Down Expand Up @@ -39,8 +39,8 @@ export type PageListProps = z.infer<typeof pageListSchema>;
type Page = z.infer<typeof pageSchema>;

/** Render a list of Notion pages as a Block Kit card. */
export function PageList({ heading, pages }: PageListProps): BotNode {
const rows: BotNode[] = [];
export function PageList({ heading, pages }: PageListProps): ChannelNode {
const rows: ChannelNode[] = [];
pages.forEach((page: Page, i: number) => {
const titleLink = page.url
? `[**${page.title}**](${page.url})`
Expand Down
Loading