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
43 changes: 43 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,46 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope
# -- Notion (Optional) --
# export NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp
# export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token

# -- Composio (Optional) --
# Connect any Composio toolkit without writing an MCP block. Two steps per app:
# add the toolkit at https://app.composio.dev (that creates its auth config),
# then list its slug below and restart the runtime. Read by the Node runtime,
# not the Python agent. Requires Node 22+ (package.json `engines`).
# Slugs are Composio's own, lowercase and unspaced — Google Calendar is
# `googlecalendar`. Take it from the toolkit's page URL at app.composio.dev. A
# typo is silent: the toolkit is configured and simply never appears.
# export COMPOSIO_API_KEY=ak_...
# Shared team accounts — every Slack user acts through the SAME connection.
# After setting this, connect each one once: `pnpm composio:connect linear`,
# then open the link it prints. Nobody can do it from Slack, and the dashboard
# binds the wrong identity (see COMPOSIO_WORKSPACE_USER_ID below).
# export COMPOSIO_TOOLKITS=linear,jira
# Personal accounts — each person connects their own from inside a thread.
# export COMPOSIO_USER_TOOLKITS=gmail,googlecalendar
# off | destructive (default) | writes
# export COMPOSIO_APPROVALS=destructive
# The Composio user_id shared toolkits run as. Defaults to
# INTELLIGENCE_CHANNEL_NAME; a shared toolkit is only connected when a connected
# account exists under this exact value. The dashboard's "connect my account"
# button binds to the dashboard's own user id and the bot never sees it —
# `pnpm composio:connect` binds to this value instead, which is why it exists.
# export COMPOSIO_WORKSPACE_USER_ID=
# Rarely needed, and case-sensitive. Read ONLY by `pnpm composio:connect`, not
# by the runtime: it pins which auth config a SHARED toolkit connects against
# when it has more than one (the script prints the ids to choose from and will
# not guess). Personal toolkits cannot be pinned — session.authorize() takes no
# auth config id and resolves one itself.
# export COMPOSIO_AUTH_CONFIGS=gmail:ac_ExAmPle2Cd,linear:ac_ExAmPle1-aB

# --- Direct Slack delivery (personal Composio toolkits need this) ----------
# Intelligence owns the Slack edge by default and no Slack token is needed here.
# The exception: a Composio connect link binds whoever opens it to the identity
# it was minted for, so it has to reach one person privately — and the managed
# adapter cannot post a private message. Setting both of these attaches a direct
# adapter that can. Both or neither; one alone fails at boot, because a Socket
# Mode connection needs the app token and every API call needs the bot token.
# From api.slack.com/apps: OAuth & Permissions for the bot token, Basic
# Information -> App-Level Tokens (scope `connections:write`) for the app token.
# export SLACK_BOT_TOKEN=xoxb-...
# export SLACK_APP_TOKEN=xapp-...
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ state.db-wal
# AWS CDK synthesis artifacts
deployment/aws/cdk.out
deployment/aws/cdk.context.json

# Subagent-driven-development scratch (ledger, briefs, review packages)
.superpowers

# Composio design and implementation plan — working documents, kept locally.
# The repo removed docs/superpowers specs and plans in 0577c63; same call here.
docs/composio-tools-design.md
docs/composio-tools-plan.md
18 changes: 17 additions & 1 deletion .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export default defineRailway(() => {

const runtime = service("runtime", {
source: github(REPO, { branch: BRANCH }),
start: "pnpm runtime",
// `pnpm run runtime`, not `pnpm runtime`: pnpm 11 added a built-in
// `runtime` command, which shadows the script and exits non-zero.
start: "pnpm run runtime",
// Rich rendering needs Chromium and its runtime libraries.
build: {
builder: "RAILPACK",
Expand All @@ -71,6 +73,20 @@ export default defineRailway(() => {
"wss://realtime.intelligence.copilotkit.ai",
INTELLIGENCE_LEARNING_CONTAINER_ID: preserve(),
INTELLIGENCE_CHANNEL_NAME: "open-tag",
// Composio runs in this process, not the agent — identity reaches tools
// only here. Every one is `preserve()`: absent `COMPOSIO_API_KEY` the
// feature does not load at all, so a deployment that never sets these
// behaves exactly as it does today.
COMPOSIO_API_KEY: preserve(),
COMPOSIO_TOOLKITS: preserve(),
COMPOSIO_USER_TOOLKITS: preserve(),
COMPOSIO_WORKSPACE_USER_ID: preserve(),
COMPOSIO_APPROVALS: preserve(),
COMPOSIO_AUTH_CONFIGS: preserve(),
// Both or neither, and only to make a connect link private to whoever
// clicks it; unset leaves Intelligence owning the Slack edge.
SLACK_BOT_TOKEN: preserve(),
SLACK_APP_TOKEN: preserve(),
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",
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ AGENT_DISPLAY_NAME=OpenTag

Both the Node runtime and the Python agent load this one root `.env`; Railway
supplies the same values as service variables. Tavily, GitHub, PostHog, Linear,
and Notion are optional — see [Optional research
Notion, and Composio are optional — see [Optional research
sources](#optional-research-sources).

`INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` already default to the
Expand Down Expand Up @@ -279,7 +279,8 @@ or one directory, and none of them require touching the Channel lifecycle.
| Which tools the agent has | [`agent/tools.py`](./agent/tools.py), [`agent/internal_sources.py`](./agent/internal_sources.py) | Sources register only when their credentials are present |
| What gets rendered in chat | [`app/components/`](./app/components), [`app/tools/`](./app/tools) | Issue cards, tables, charts, diagrams |
| Mentions, commands, triggers | [`app/channel.tsx`](./app/channel.tsx) | The whole Channel surface in one file |
| Which writes need approval | [`agent/write_confirmation.py`](./agent/write_confirmation.py) | The interceptor that emits `confirm_write` |
| Which writes need approval | [`agent/write_confirmation.py`](./agent/write_confirmation.py) | The interceptor that emits `confirm_write`; `COMPOSIO_APPROVALS` is the equivalent dial for Composio |
| Which connected apps people can use | `COMPOSIO_TOOLKITS`, `COMPOSIO_USER_TOOLKITS` | One slug per app. Adding one later needs no code change — but it does need a dashboard step and a restart |
| The deployment topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services, declared as code |

If you are customizing with a coding agent, read [`AGENTS.md`](./AGENTS.md)
Expand All @@ -295,6 +296,8 @@ workflow instead of letting an agent improvise one.
- File-aware prompts.
- A LangGraph interrupt and resumable confirmation card before Linear or Notion
writes.
- Optional Composio toolkits resolved per turn — a shared team account, each
person's own, or both at once — behind their own approval gate.
- Graceful, idempotent shutdown for Channels, HTTP, and the rendering browser.
- Nullable parent-message ID normalization through `SanitizingHttpAgent`.

Expand All @@ -312,6 +315,8 @@ CopilotKit Intelligence
│ outbound websocket from your runtime
runtime (Node + CopilotRuntime with embedded Channels)
├── Composio toolkits (optional; shared or per-Slack-user)
│ AG-UI
agent (Python + LangGraph deepagents)
Expand Down Expand Up @@ -363,6 +368,16 @@ knowledge work, and renders UI from model knowledge.
| `LINEAR_API_KEY` | Hosted Linear MCP |
| `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it |
| `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` |
| `COMPOSIO_API_KEY` + `COMPOSIO_TOOLKITS` and/or `COMPOSIO_USER_TOOLKITS` | Any Composio toolkit — shared team accounts, or each person's own connected from inside a thread |

Composio is the one that grows without code. Adding Salesforce six months from
now is two steps: add the toolkit at [app.composio.dev](https://app.composio.dev),
then add its slug to a list and restart the runtime. No new MCP block, no
TypeScript, no test change. Two steps, though — neither of them automatic, and a
shared team account takes a third: `pnpm composio:connect <slug>` once, to bind
the connection to the identity shared calls actually run as. See
[`setup.md`](./setup.md#composio) for approval modes, shared versus personal
accounts, and what Google's consent screen will tell your users.

Every Linear and Notion mutation is intercepted in code before the MCP request
runs. The interceptor emits `confirm_write` and proceeds only after approval;
Expand Down
62 changes: 62 additions & 0 deletions app/channel-components.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* What the Channel is actually told to register.
*
* A click that arrives after the in-process cache is gone is served by
* re-rendering the named component from `createChannel({ components })`. A card
* missing from that list resolves to nothing, `dispatch` raises
* `ActionExpiredError`, and the Channel swallows it (`create-channel.js`) — so
* the person clicks and nothing happens, with no error anywhere to explain it.
*
* The assertion is on the argument `createChannel` receives, not on any
* constant this file could read: the wiring is the thing that has to be right,
* and a future edit that passes a different array must fail here.
*/
import { describe, expect, it, vi } from "vitest";

type ChannelsModule = typeof import("@copilotkit/channels");
type CreateChannelOptions = Parameters<ChannelsModule["createChannel"]>[0];

const optionsSeen: CreateChannelOptions[] = [];

vi.mock("@copilotkit/channels", async (importOriginal) => {
const actual = await importOriginal<ChannelsModule>();
return {
...actual,
createChannel: (options: CreateChannelOptions) => {
optionsSeen.push(options);
return actual.createChannel(options);
},
};
});

const { FakeAgent } = await import("@copilotkit/channels");
const { createOpenTagChannel } = await import("./channel.js");

/** The `components` array of the most recent `createChannel` call, by name. */
function registeredComponentNames(): string[] {
const components = optionsSeen.at(-1)?.components;
if (!Array.isArray(components)) {
throw new Error("createChannel was not given a components array");
}
return components.map((component) =>
typeof component === "function" ? component.name : String(component),
);
}

describe("createOpenTagChannel component registration", () => {
it("registers every card whose buttons outlive the turn that posted them", () => {
createOpenTagChannel("opentag", new FakeAgent());

// `ConfirmToolRun`'s entire premise is that the agent's turn already ended,
// and a `ConnectAccount` click works perfectly cold — it mints a fresh link
// and needs nothing from the process that posted the card. Dropping either
// is a restart-only breakage that no other test would notice.
expect(registeredComponentNames()).toEqual(
expect.arrayContaining([
"ConfirmWrite",
"ConfirmToolRun",
"ConnectAccount",
]),
);
});
});
60 changes: 60 additions & 0 deletions app/channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,66 @@ describe("createOpenTagChannel", () => {
);
});

it("answers a mention once and ignores later revisions of it", async () => {
const { adapter, agent, channel } = makeChannel();
const revision = (kind: "created" | "updated", revisionId: string) => ({
conversationKey: "revised-thread",
replyTarget: {},
userText: "@Kite say hi",
platform: "slack",
actor: { id: "U1", kind: "human" as const },
operation: {
kind,
logicalMessageId: "m1",
revisionId,
mentioned: true,
},
});

await channel.ɵruntime.start();
await adapter.getSink().onTurn(revision("created", "m1"));
// Slack re-announces the message as changed for every reply added to its
// thread, so these arrive on their own — nobody edited anything.
await adapter.getSink().onTurn(revision("updated", "m1-r2"));
await adapter.getSink().onTurn(revision("updated", "m1-r3"));

expect((agent as CapturingAgent).calls).toHaveLength(1);
});

it("ignores revisions of an unmentioned message in a subscribed thread", async () => {
const { adapter, agent, channel } = makeChannel();

await channel.ɵruntime.start();
await adapter.getSink().onTurn({
conversationKey: "revised-subscribed-thread",
replyTarget: {},
userText: "@Kite watch this thread",
platform: "slack",
actor: { id: "U1", kind: "human" },
operation: {
kind: "created",
logicalMessageId: "m1",
revisionId: "m1",
mentioned: true,
},
});
await adapter.getSink().onTurn({
conversationKey: "revised-subscribed-thread",
replyTarget: {},
userText: "what next?",
platform: "slack",
actor: { id: "U1", kind: "human" },
operation: {
kind: "updated",
logicalMessageId: "m2",
revisionId: "m2-r2",
mentioned: false,
},
});

expect((agent as CapturingAgent).calls).toHaveLength(1);
});

it("keeps an existing unsubscribed conversation mention-only and offers subscribe", async () => {
const { adapter, agent, channel, stateStore } = makeChannel();
adapter.messages = [
Expand Down
Loading