feat(composio): optional Composio tool integration, shared or per-user - #66
Open
mxmzb wants to merge 8 commits into
Open
feat(composio): optional Composio tool integration, shared or per-user#66mxmzb wants to merge 8 commits into
mxmzb wants to merge 8 commits into
Conversation
@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
marked this pull request as ready for review
August 25, 2026 15:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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_APPROVALSisoff,destructive(default), orwrites. The defaultgates 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 thepending 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-corehasno
forwardedProps, so there is no way to hand a structured, LLM-proof user idto the Python agent — identity reaches it only as prompt text, which cannot
scope credentials.
ChannelToolContextdoes 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 Composiosession hands the agent
COMPOSIO_REMOTE_BASH_TOOLandCOMPOSIO_REMOTE_WORKBENCH— remote shell and Python — with no opt-in. OpenTagalready 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/SIGTERMhandlers that flush over the network and then re-raise viaprocess.kill. Againstserver.ts's async drain that truncates gracefulshutdown — 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_idit was minted for, so a link for one person in ashared 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 sharedcalls 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
pnpm check-typespnpm testcd agent && uv run pytestcd deployment/aws && pnpm testdiagnostics: []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_sessiondoes not exist — it issessions.create, andtool_routeris a deprecated alias.session.tools()returns a fixed six-tool router, not a toolkit's tools.session.executenever throws on tool failure; it returns{ data, error, logId }either way, so a try/catch alone reads every failedwrite as a success.
thread.awaitChoicerejects outright on managed Channels and never posts.tagsfilter takes a bare array; the{ enabled: [...] }shapein 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(): absentCOMPOSIO_API_KEYnothing isconstructed, 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_KEYas a fieldin 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=trueadmits the two token fields. They are conditionalrather 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— pnpm11 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.jsonpinsrxjs: ^7.8.2underpnpm.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 settingSLACK_BOT_TOKENandSLACK_APP_TOKENtogether attaches a direct adapter thatsupports
postEphemeral. Both or neither — one alone cannot start a Socket Modeadapter, 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-slackshould 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:
readwritedestructivedestructiveTrashing 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=writesgates itfor 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.