Skip to content
Draft
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
31 changes: 31 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,34 @@ 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
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
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",
]),
);
});
});
40 changes: 35 additions & 5 deletions app/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ import { appCommands } from "./commands/index.js";
import { IssueCard, IssueList, PageList } from "./components/index.js";
import { createAppContext } from "./context/app-context.js";
import { DEFAULT_AGENT_DISPLAY_NAME } from "./env.js";
import { ConfirmWrite } from "./human-in-the-loop/index.js";
import {
ConfirmToolRun,
ConfirmWrite,
ConnectAccount,
} from "./human-in-the-loop/index.js";
import { parseConfirmWriteInterrupt } from "./interrupt.js";
import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js";
import { IncidentCard } from "./tools/showcase-tools.js";
import { RenderChart } from "./tools/render-chart.js";
import { composioTools } from "./tools/composio/index.js";
import { createAppTools } from "./tools/index.js";
import {
subscribeThreadTool,
Expand All @@ -39,16 +44,29 @@ export function createOpenTagChannel(
tools: createAppTools(agentDisplayName),
context: [...createAppContext(agentDisplayName)],
commands: appCommands,
// Not bookkeeping: once the in-process cache is gone, a click is served by
// re-rendering the named component from here. An unregistered card's
// buttons raise `ActionExpiredError`, which the Channel swallows — so the
// person clicks and nothing happens at all. `ConfirmToolRun` and
// `ConnectAccount` are the ones that make this load-bearing; both exist to
// be clicked minutes later, long after their turn ended.
components: [
IssueCard,
IssueList,
PageList,
IncidentCard,
ConfirmWrite,
ConfirmToolRun,
ConnectAccount,
RenderChart,
],
});

// Built once, here, rather than per turn: an unconfigured deployment gets an
// empty array and never constructs the SDK, and a misconfigured one prints
// its warnings at boot instead of once per message.
const composio = composioTools(process.env, name);

type MessageHandlerInput = Parameters<
Parameters<typeof channel.onMessage>[0]
>[0];
Expand Down Expand Up @@ -84,7 +102,10 @@ export function createOpenTagChannel(
if (message.actor.kind === "bot" || message.actor.kind === "app") return;

if (await thread.isSubscribed()) {
await runAgentSafely({ thread, message }, [unsubscribeThreadTool]);
await runAgentSafely({ thread, message }, [
unsubscribeThreadTool,
...composio,
]);
return;
}

Expand All @@ -101,18 +122,27 @@ export function createOpenTagChannel(

if (isNewConversation) {
await thread.subscribe();
await runAgentSafely({ thread, message }, [unsubscribeThreadTool]);
await runAgentSafely({ thread, message }, [
unsubscribeThreadTool,
...composio,
]);
return;
}

await runAgentSafely({ thread, message }, [subscribeThreadTool]);
await runAgentSafely({ thread, message }, [
subscribeThreadTool,
...composio,
]);
});

channel.onMessage(async ({ thread, message }) => {
if (message.actor.kind === "bot" || message.actor.kind === "app") return;

if (await thread.isSubscribed()) {
await runAgentSafely({ thread, message }, [unsubscribeThreadTool]);
await runAgentSafely({ thread, message }, [
unsubscribeThreadTool,
...composio,
]);
}
});

Expand Down
Loading