Skip to content

feat(composio): optional Composio tool integration, shared or per-user - #66

Open
mxmzb wants to merge 8 commits into
mainfrom
feat/composio-tools-design
Open

feat(composio): optional Composio tool integration, shared or per-user#66
mxmzb wants to merge 8 commits into
mainfrom
feat/composio-tools-design

Conversation

@mxmzb

@mxmzb mxmzb commented Aug 21, 2026

Copy link
Copy Markdown

Adds an optional Composio integration so a self-hoster can connect third-party
apps by setting an environment variable, instead of hand-writing an MCP block
per app.

Verified end to end in a live Slack workspace, including the approval gate, and
wired into both deployments. Details at the bottom, along with the four commits
that live run produced.

What it does

Two paths, independently configurable:

COMPOSIO_API_KEY=ak_...
COMPOSIO_TOOLKITS=linear,jira               # one shared team identity
COMPOSIO_USER_TOOLKITS=gmail,googlecalendar # each person's own account

Shared toolkits run as a single identity everyone in Slack reaches.
Personal toolkits run as whoever is asking — "what's on my calendar" uses
your Google account, and your colleague's question uses theirs. People connect
their own accounts from a card in the thread.

With no COMPOSIO_API_KEY, none of it loads: no client, no tools, no log noise.

Approvals

COMPOSIO_APPROVALS is off, destructive (default), or writes. The default
gates deletes and lets creates and updates through — measured against the live
API, that is 9 of gmail's 63 tools, 3 of linear's 47, and 8 of googlecalendar's
49.

Managed Channels cannot block a tool handler on a user's choice
(supportsBlockingChoice: false), so a gated call posts a card, registers the
pending call under a token, and ends the turn. The Approve handler executes and
rewrites the card in place.

Design notes worth reviewing

It lives entirely in the TypeScript runtime. @copilotkit/channels-core has
no forwardedProps, so there is no way to hand a structured, LLM-proof user id
to the Python agent — identity reaches it only as prompt text, which cannot
scope credentials. ChannelToolContext does carry a platform-verified actor.
Identity exists in TypeScript and nowhere else, so the code that needs identity
lives there.

We wrap the SDK rather than exposing Composio's own router tools. Their
hosted executor sends calls from the model straight to Composio, bypassing our
approval card entirely. Wrapping is what makes the gate un-bypassable.

Sessions always pass workbench: { enable: false }. A default Composio
session hands the agent COMPOSIO_REMOTE_BASH_TOOL and
COMPOSIO_REMOTE_WORKBENCH — remote shell and Python — with no opt-in. OpenTag
already has a sandbox behind separate credentials; a second ungated one arriving
as a side effect of naming a toolkit is a surprise, not a feature.

Composio's telemetry is disabled. Enabled by default, it registers its own
SIGINT/SIGTERM handlers that flush over the network and then re-raise via
process.kill. Against server.ts's async drain that truncates graceful
shutdown — and SIGTERM is how Railway and ECS stop a container.

A connect link is never posted to a channel. A Composio link binds whoever
completes it to the user_id it was minted for, so a link for one person in a
shared channel lets someone else attach their mailbox to that identity. The card
carries a button; the link is minted for whoever clicks and delivered privately.

An unmapped tool slug is treated as destructive. Tools are classified from
Composio's MCP behaviour tags, fetched with limit: 300. A slug past that cap,
hallucinated, or injected would otherwise be treated as a plain write — and a
plain write is not gated in the default mode.

Shared toolkits need one terminal step

pnpm composio:connect <toolkit> mints a link bound to the identity shared
calls actually run as. The Composio dashboard's own "Connect my account" button
binds a different user id, so it does not work for this — connections are
strictly scoped per user id, verified live.

Personal toolkits skip this; each person connects their own from a thread.

Verification

Check Result
pnpm check-types clean
pnpm test 431 passed, 36 files
cd agent && uv run pytest 180 passed
cd deployment/aws && pnpm test 14 passed
Railway IaC diagnostics: []
Live Slack workspace connect flow and all four approval checks verified

Behaviour here was measured against the live API rather than taken from
published documentation, which was wrong on several counts worth knowing if you
review this against Composio's docs:

  • tool_router.create_session does not exist — it is sessions.create, and
    tool_router is a deprecated alias.
  • session.tools() returns a fixed six-tool router, not a toolkit's tools.
  • session.execute never throws on tool failure; it returns
    { data, error, logId } either way, so a try/catch alone reads every failed
    write as a success.
  • thread.awaitChoice rejects outright on managed Channels and never posts.
  • The session tags filter takes a bare array; the { enabled: [...] } shape
    in the API reference is rejected.

Deploy config

Both deployments carry the Composio variables and the two Slack tokens now, so
the feature is no longer local-only.

Railway takes all of them as preserve(): absent COMPOSIO_API_KEY nothing is
constructed, so a deployment that sets none of them behaves as it does today.

AWS splits them the way that stack already splits everything — non-secret
settings as CDK context on the runtime container, COMPOSIO_API_KEY as a field
in the JSON application secret. Naming a toolkit is what admits that field,
which is one knob rather than a flag that can disagree with the toolkit lists,
and slackDirectDelivery=true admits the two token fields. They are conditional
rather than always-present-and-empty because ECS fails a task whose secret is
missing a named field: a deployment using neither integration must not name
them.

Everything lands on the runtime container only. Identity reaches Composio's
tools there and nowhere else.

That work also fixes the Railway start command, which was pnpm runtime — pnpm
11 added a built-in command by that name that shadows the script and exits
non-zero, so the service would have failed to boot the first time the pinned
pnpm crossed that major.

Unrelated issue noticed

package.json pins rxjs: ^7.8.2 under pnpm.overrides, and pnpm 10.33 prints
"The 'pnpm' field in package.json is no longer read by pnpm." That pin has
been silently inert. Not touched here.

Added after review, during live Slack verification

Three commits land on top of the Composio work, in the order a live run
surfaced them.

Direct Slack delivery when both Slack tokens are set. The connect flow has
to hand one person a link nobody else in the thread can open, because a connect
link binds whoever opens it to the identity it was minted for. Intelligence's
managed adapter declares supportsEphemeral: false, so setting
SLACK_BOT_TOKEN and SLACK_APP_TOKEN together attaches a direct adapter that
supports postEphemeral. Both or neither — one alone cannot start a Socket Mode
adapter, so it fails at boot rather than falling back silently. Unset, which
stays the normal case, nothing changes.

One connect card per person and toolkit. The tool's "stop here" return
string is only advice. Without a guard the agent searched, was told truthfully
that the app is not connected, posted a card, searched again, and posted
another — a dozen identical cards in one turn until the graph's step limit cut
it off.

Answer a mention once, not once per revision. A single "@Roger say hi"
produced about fifty identical answers in eight seconds. Slack re-announces a
message as changed for reasons unrelated to anybody editing its text — adding a
reply to its thread is enough — and every announcement arrived as a fresh
mention with a new revision id, which the ingress dedup (keyed on that id)
cannot collapse. Answering one posted a reply, the reply revised the message
again, and the loop fed itself. An instrumented run showed one original message
and ten revisions of it among eleven turns. Verified after the fix: one mention,
one agent run, one reply.

The last one is a workaround for an upstream bug — @copilotkit/channels-slack
should not turn parent-metadata changes into mention turns at all. Worth fixing
in the monorepo so no Channels app needs this guard.

Verified end to end in Slack

The approval gate, in the same workspace:

Call Effect Composio reports What happened
Fetch inbox read ran, no card
Move to trash write ran, no card — see below
Batch delete destructive carded, turn ended, ran only after Approve
Batch delete, runtime restarted before the click destructive card reported itself expired

Trashing a message is not gated in the default mode, because Composio tags it as
an ordinary write rather than destructive. That reads as reasonable — trash is
recoverable, permanent delete is not — and COMPOSIO_APPROVALS=writes gates it
for anyone who disagrees.

Asking Roger for the last three Gmail messages posted exactly one Connect Gmail
card, the button handed out a link private to whoever clicked it, and after
connecting, the same question returned three real inbox messages. The card
itself stays in the thread on purpose — anyone else there can click it to
connect their own account — and only the link behind the button is per-person,
which is the part that needs postEphemeral.

mxmzb and others added 8 commits August 21, 2026 20:46
@composio/core depends directly on openai ^7.2.0, so installing it
deduped the shared transitive openai from 6.47.0 to 7.5.0 across
@copilotkit/runtime, @langchain/core, langchain and @ag-ui/langgraph.
That is inside @copilotkit/runtime's declared peer range
(^4.85.1 || >=5.0.0), so it is an allowed dedupe rather than a violated
constraint, and pinning back is not possible without breaking Composio.

openai@7 declares engines node >=22 and this repository declared none.
Every deploy target already satisfies it -- CI runs 22.x and the
Dockerfile pins node:22.16.0 by digest -- but a self-hoster on Node 20
would otherwise hit a confusing runtime failure instead of a clear
install error.
Adds an optional Composio integration, inert unless COMPOSIO_API_KEY is
set. Toolkits listed in COMPOSIO_TOOLKITS run as one shared team
identity; toolkits in COMPOSIO_USER_TOOLKITS run as the Slack user who
asked, who connects their own account from a card in the thread.

Lives entirely in the TypeScript runtime. Identity is only available
there: @copilotkit/channels-core has no forwardedProps, so a structured
user id cannot reach the Python agent, while ChannelToolContext carries
a platform-verified actor.

Three Channel tools wrap the SDK rather than exposing Composio's own
router tools, because the hosted executor would send calls from the
model straight to Composio and bypass the approval gate entirely.
Toolkits are large -- gmail 63 tools, linear 47, googlecalendar 49 --
so search returns five candidates with their input schemas inline.

COMPOSIO_APPROVALS chooses when a human must confirm: off, destructive
(default), or writes. Managed Channels cannot block on a choice, so a
gated call posts a card, registers the pending call under a token, and
ends the turn; the Approve handler executes and rewrites the card.

Security properties, each covered by tests: sessions always disable
Composio's remote bash and Python sandbox, which are on by default; a
connect link is minted for whoever clicks and delivered privately,
never posted, because a link binds the completing user's account to the
id it was minted for; an unmapped tool slug is treated as destructive so
it cannot slip past the gate; a personal-scope approval can only be
actioned by the person it was composed for.

Includes scripts/composio-connect.ts, the only way to bind a shared
toolkit to the identity shared calls actually run as -- the dashboard's
connect button binds a different user id.
Covers the three setup steps per app, the extra terminal step a shared
toolkit needs, and the frictions a self-hoster meets: Google's consent
screen names Composio rather than OpenTag; self-branding without Google
verification is worse, not better, because users get an unverified-app
warning; all of a person's Composio connections share one Google grant,
so revoking disconnects every app at once; and a Workspace admin can
block the flow before it reaches OpenTag.

Also documents that the approval card renders argument values into the
channel, so approving an email puts its body in front of the thread.
That is deliberate -- an approver who cannot see what they are
approving cannot approve it -- but an operator should weigh it before
enabling personal toolkits.
Both are working documents. 0577c63 removed docs/superpowers specs and
plans from this repository; this follows that call rather than
reintroducing the same kind of file under a different path. They stay on
disk for whoever is working on the feature.
Intelligence owns the Slack edge and no Slack token belongs in this
repository, with one exception: the managed adapter declares
`supportsEphemeral: false`, and the Composio connect flow has to hand one
person a link nobody else in the thread can open — a connect link binds
whoever opens it to the identity it was minted for.

Setting SLACK_BOT_TOKEN and SLACK_APP_TOKEN together attaches a direct
adapter that supports `postEphemeral`. Both or neither: one alone cannot
start a Socket Mode adapter, so it fails loudly at boot instead of
silently falling back. Unset — the normal case — nothing changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tool's "stop here" return string is only advice. Left with it, the
agent searched, was told truthfully that the app is not connected, posted
a card, searched again, was told the same true thing, and posted another —
a dozen identical cards in one turn until the graph's step limit cut it
off.

A per-person registry of live cards makes the second attempt return "one
is already waiting" instead. Keyed by person rather than by conversation:
the card belongs to them, and the public `Thread` a tool receives exposes
no conversation id. Cleared when they click through, so a later genuine
failure can still post a fresh card, and bounded like the pending-approval
registry since entries outlive the turn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One "@Roger say hi" produced fifty identical answers in about eight
seconds. Slack re-announces a message as changed for reasons that have
nothing to do with somebody editing its text — adding a reply to its
thread is enough — and every announcement arrived as a fresh mention
carrying a new revision id, so the ingress dedup (keyed on that id) could
not collapse them. Answering one posted a reply, the reply revised the
message again, and the next revision asked the same question.

An instrumented run showed the shape plainly: of eleven turns from one
mention, one was the original message and ten were revisions of it. Only
the original starts a turn now.

The underlying bug is upstream in the Slack adapter, which should not be
turning parent-metadata changes into mention turns at all; this guard
keeps OpenTag correct until that lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ents

Composio worked locally and was inert everywhere else: neither
`.railway/railway.ts` nor the AWS stack declared its variables, so a value
typed into a dashboard did not survive an IaC apply and the CDK task never
saw one at all.

Railway takes all six Composio variables plus the two Slack tokens as
`preserve()`. Absent `COMPOSIO_API_KEY` nothing is constructed, so a
deployment that sets none of them behaves exactly as before.

AWS splits them the way that stack already splits everything: the
non-secret ones are CDK context on the runtime container, and
`COMPOSIO_API_KEY` is a field in the JSON application secret. Naming a
toolkit is what admits that field — one knob instead of a flag that can
disagree with the toolkit lists — and `slackDirectDelivery=true` admits the
two token fields. This matters because ECS fails a task whose secret is
missing a named field: a deployment using neither integration must not name
them, which is why they are conditional rather than always present with an
empty default.

They land on the runtime container only. Identity reaches Composio's tools
there and nowhere else; the Python agent gets no verified actor and so
cannot scope credentials to a person.

Also fixes the Railway start command, which was `pnpm runtime`. pnpm 11
added a built-in command by that name that shadows the script and exits
non-zero, so the service would have failed to boot the first time the
pinned pnpm crossed that major.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mxmzb
mxmzb marked this pull request as ready for review August 25, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant