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
36 changes: 23 additions & 13 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,32 @@ AGENT_URL=http://localhost:8200/api/copilotkit/agent/triage/run
# AGENT_AUTH_HEADER=Bearer ...

# ── 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.
# Runs the SAME KiteBot over the CopilotKit Intelligence Realtime Gateway
# instead of a native adapter — this process holds NO Slack tokens
# (Intelligence owns the Slack edge). Wired up via `createChannel` +
# `CopilotRuntime({ channels })` + `createCopilotNodeListener`
# (@copilotkit/runtime/v2) — there are NO org/project/channel IDs; the
# runtime derives its infra ids from the API creds below plus the channel
# name. AGENT_URL / AGENT_AUTH_HEADER (above) still point the channel at
# the agent brain.
# NOTE: the example values below point at CopilotKit's own dev Intelligence
# host (dev.intelligence.copilotkit.ai) — replace both with YOUR project's
# Intelligence endpoint before deploying.
# Base URL of your CopilotKit Intelligence deployment's app API.
# INTELLIGENCE_API_URL=https://dev.intelligence.copilotkit.ai
# 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.
# Project runtime API key (cpk-{projectId}_...), presented as the socket auth
# token; secret — set in your secret store, not committed here.
# 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_...
# Registered channel name (lowercase kebab); defaults to kite-opentag.
# INTELLIGENCE_CHANNEL_NAME=kite-opentag
# The channel host's HTTP routes (agent/run, threads/*, memories/*) are CLOSED
# by default — the managed channel activates over the gateway WebSocket, not
# over HTTP, so nothing needs them and an open port would be an unauthenticated
# proxy to AGENT_URL. Set this to open them behind `Authorization: Bearer <token>`
# (e.g. to point a local CopilotKit frontend at this runtime). Secret.
# CHANNEL_HTTP_TOKEN=

# Model for the BuiltInAgent. The runtime is OpenAI-only (it uses OpenAI's
# Responses API via TanStack AI's `openaiText` adapter, needed for the
Expand Down
49 changes: 40 additions & 9 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { defineRailway, github, preserve, project, service } from "railway/iac";
// (see README "Deploy to Railway").
//
// Two topology invariants this file encodes:
// 1. Ports are pinned as explicit service variables (NOT left to Railway's
// auto-injected $PORT) so each service's listen port and the ${{svc.PORT}}
// its peers dial always agree.
// 1. Ports for peer-dialed services (agent, notion-mcp) are pinned as explicit
// service variables (NOT left to Railway's auto-injected $PORT) so each
// service's listen port and the ${{svc.PORT}} its peers dial always agree.
// The outbound-only channel host has no peer dialing it, so it uses
// Railway's injected $PORT (app/managed.ts defaults 8300).
// 2. Services reached over Railway private networking must bind :: (all
// interfaces) — private DNS (RAILWAY_PRIVATE_DOMAIN) resolves to IPv6, and
// legacy environments are IPv6-only. A service bound to 127.0.0.1/0.0.0.0
Expand Down Expand Up @@ -89,10 +91,24 @@ export default defineRailway(() => {
},
});

// KiteBot channel host — runs the bot over the Intelligence Realtime Gateway.
// KiteBot channel host — runs the bot via CopilotKit Intelligence v2:
// createChannel() feeds a CopilotRuntime({ channels }) into
// createCopilotNodeListener(). There are no org/project/channel IDs to wire
// up: the runtime derives that identity from the API credentials plus the
// channel name below.
const channel = service("channel", {
source: github(REPO),
start: "pnpm channel",
// The channel host renders inline charts/diagrams via Playwright/Chromium
// (app/render/browser.ts → chromium.launch). Install the browser at build
// time; PLAYWRIGHT_BROWSERS_PATH=0 (env below) puts it in node_modules so it
// persists into the deploy image, and RAILPACK_DEPLOY_APT_PACKAGES supplies
// the shared libs Chromium needs at runtime. Without this, render_chart /
// render_diagram fail on the deployed channel. Mirrors the kite reference.
build: {
buildCommand:
"pnpm install && npx playwright install --with-deps chromium",
},
deploy: {
// Parity with agent/notion-mcp: restart the long-running channel host on
// crash instead of leaving KiteBot silently offline.
Expand All @@ -103,17 +119,32 @@ export default defineRailway(() => {
// brain: points at the agent service over private networking. The agent
// pins PORT=8123, so ${{agent.PORT}} resolves to the port uvicorn binds.
AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/",
// Chromium for inline chart/diagram rendering: install the browser into
// node_modules (0) so it ships with the app, and provide the shared libs
// in the deploy image (Railpack strips build-layer apt packages).
PLAYWRIGHT_BROWSERS_PATH: "0",
RAILPACK_DEPLOY_APT_PACKAGES:
"fonts-liberation fonts-noto-color-emoji fonts-unifont libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libexpat1 libfontconfig1 libfreetype6 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxrender1 libxshmfence1",
// INTELLIGENCE_CHANNEL_NAME is intentionally NOT set here: app/managed.ts
// already defaults it to "kitebot", and leaving it unmanaged means a
// already defaults it to "kite-opentag", and leaving it unmanaged means a
// deployer can set their own channel name in the Railway UI without a
// later `config apply` clobbering it. Set it in the channel service's
// Variables if your Intelligence channel is named something else.
// secrets (set in Railway UI):
//
// All three INTELLIGENCE_* connection vars below are deployer-supplied
// secrets/values (from your CopilotKit Intelligence project) — declared
// with preserve() so they're never hardcoded here, and `railway config
// apply` never clobbers what you set in the Railway UI (see README
// "Deploy to Railway"):
INTELLIGENCE_API_URL: preserve(),
INTELLIGENCE_GATEWAY_WS_URL: preserve(),
INTELLIGENCE_API_KEY: preserve(),
INTELLIGENCE_ORG_ID: preserve(),
INTELLIGENCE_PROJECT_ID: preserve(),
INTELLIGENCE_CHANNEL_ID: preserve(),
// CHANNEL_HTTP_TOKEN is intentionally NOT set: the channel host's HTTP
// routes are closed unless it is, and nothing here needs them (this
// service has no public domain and no healthcheck, and the managed
// channel activates over the gateway WebSocket). preserve() so a deployer
// who does open the surface isn't clobbered by a later `config apply`.
CHANNEL_HTTP_TOKEN: preserve(),
},
});

Expand Down
16 changes: 6 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ OpenTag is a thin layer on top of a handful of CopilotKit packages. The `pnpm in
| Package | When you need 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`. **Required for the recommended `pnpm channel` (Intelligence Gateway) mode**; omit it only if you run self-hosted mode exclusively. |

**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 @@ -81,11 +80,9 @@ SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...

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

**3. Run it:**
Expand Down Expand Up @@ -117,7 +114,7 @@ OpenTag is deliberately small and hackable:
- **Copy `app/` to start your own bot.** It's the platform-agnostic bot (tools, components, the
human-in-the-loop gate). `runtime.ts` is the agent backend: one CopilotKit `BuiltInAgent` (an
LLM + optional MCP tools — no Python, no LangGraph), served over AG-UI.
- **One platform, or all of them.** `createBot` takes an array of adapters; set the secrets for
- **One platform, or all of them.** `createChannel` takes an array of adapters; set the secrets for
whichever platform(s) you want and the bot starts an adapter for each.

The full architecture, the file-by-file map, and every integration live in
Expand Down Expand Up @@ -193,15 +190,14 @@ railway config apply # from the repo root: provisions agent + notion-mcp
service from `resources` in [`.railway/railway.ts`](./.railway/railway.ts) and delete the
agent's `NOTION_MCP_URL` / `NOTION_MCP_AUTH_TOKEN` lines; the agent still runs (chat, UI, and —
with `TAVILY_API_KEY` — web research).
- **`channel`** — `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`,
`INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence
project + channel).
- **`channel`** — `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`
(from your CopilotKit Intelligence project).

The inter-service URLs/ports are wired for you in [`.railway/railway.ts`](./.railway/railway.ts).
Two values are left unmanaged by the IaC so a UI override survives `railway config apply`: the
agent's `OPENAI_MODEL` (defaults to `gpt-5.5`) and the channel's `INTELLIGENCE_CHANNEL_NAME`
(defaults to `kitebot`). Set either in that service's *Variables* to change it — e.g. set
`INTELLIGENCE_CHANNEL_NAME` if your Intelligence channel isn't named `kitebot`.
(defaults to `kite-opentag`). Set either in that service's *Variables* to change it — e.g. set
`INTELLIGENCE_CHANNEL_NAME` if your Intelligence channel isn't named `kite-opentag`.

Applying the config creates the services and their wiring; **KiteBot goes live only once the
secrets are set and the `channel` service connects** — that's when your Intelligence dashboard
Expand Down
91 changes: 87 additions & 4 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/channels-ui";
import type { BotNode } from "@copilotkit/channels-ui";
import type { ChannelNode } from "@copilotkit/channels-ui";
import { appCommands } from "../index.js";
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 Expand Up @@ -281,4 +281,87 @@ describe("example slash commands", () => {
expect.stringMatching(/couldn.t send a private preview|file-issue/i),
);
});

it("/preview logs and posts a fallback when postEphemeral rejects (network/API error)", async () => {
const preview = byName("preview");
const postEphemeral = vi
.fn()
.mockRejectedValueOnce(new Error("slack api unavailable"));
const post = vi.fn().mockResolvedValue({ id: "1" });
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});

// Must not throw/reject — a bare rejection here would previously only
// surface as an unhandledRejection with no user feedback.
await preview.handler({
thread: { postEphemeral, post } as never,
command: "preview",
text: "Login broken",
options: {},
user: { id: "U1", name: "Ada" },
platform: "discord",
} as never);

expect(postEphemeral).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalled();
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith(
expect.stringMatching(/couldn.t send a private preview|file-issue/i),
);

consoleError.mockRestore();
});

it("/file-issue logs and posts a fallback when openModal rejects (network/API error)", async () => {
const cmd = byName("file-issue");
const post = vi.fn().mockResolvedValue({ id: "1" });
const openModal = vi
.fn()
.mockRejectedValueOnce(new Error("view_id expired"));
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});

// Must not throw/reject — a bare rejection here would previously only
// surface as an unhandledRejection with no user feedback.
await cmd.handler({
thread: { post } as never,
command: "file-issue",
text: "",
options: {},
user: { id: "U1" },
platform: "slack",
openModal,
} as never);

expect(openModal).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalled();
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledWith(
expect.stringMatching(/couldn.t open the form/i),
);

consoleError.mockRestore();
});

it("/agent does not throw when the bare usage post itself rejects", async () => {
const thread = fakeThread();
thread.post.mockRejectedValueOnce(new Error("channel archived"));
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});

// Even the fallback-of-last-resort (posting the usage string) can reject;
// there's nowhere further to degrade to, but it must still be logged
// rather than thrown.
await byName("agent").handler(
ctx({ command: "agent", text: "", thread: thread as never }),
);

expect(thread.post).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalled();

consoleError.mockRestore();
});
});
Loading