feat(composio): move Composio into the agent, behind one approval gate - #68
Merged
Conversation
mxmzb
marked this pull request as draft
September 1, 2026 22:25
mxmzb
marked this pull request as ready for review
September 4, 2026 15:51
Add scoped Composio sessions, identity handling, tool discovery and execution, connection authorization, and unified write approval support. Harden agent authentication and AG-UI event delivery, with coverage for SDK contracts, concurrency, identity, approvals, and packaging. Co-authored-by: Maxim Zubarev <maxim.zubarev@icloud.com>
Forward trusted actor identity, render account connection flows through managed Channels, and bind approval decisions to graph interrupts. Upgrade the paired CopilotKit packages and cover message routing, connection delivery, and approval recovery. Co-authored-by: Maxim Zubarev <maxim.zubarev@icloud.com>
Declare the agent and runtime environment contract for Railway, AWS, and Docker, preserve upgrade-safe defaults, and make CI typecheck the AWS deployment package. Co-authored-by: Maxim Zubarev <maxim.zubarev@icloud.com>
Document shared and personal scopes, connection flows, auth requirements, deployment configuration, and the coordinated runtime and Channel upgrade. Co-authored-by: Maxim Zubarev <maxim.zubarev@icloud.com>
tylerslaton
force-pushed
the
feat/composio-in-agent
branch
from
September 8, 2026 15:52
697f270 to
27c5b2e
Compare
tylerslaton
approved these changes
Sep 8, 2026
tylerslaton
approved these changes
Sep 8, 2026
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.
Supersedes #66. Same feature, on the side of the boundary Tyler argued for.
Verified end to end against a live Slack workspace: a personal Gmail connect
link reaches one person privately, and an approved call runs in that person's
own account.
Why this rather than #66
#66 put Composio in the Channel. The objection was that it defines agent
behaviour in the surface: the OpenTag agent, run on its own, had no Composio
capability at all.
The evidence that objection was right is in our own docs.
setup.mdon thatbranch said Composio calls were "gated separately and differently" from Linear
and Notion writes — a second approval card, its own dial, and no graph to resume.
One product, two approval systems. That sentence made the case better than the
line count did.
The rule this settles on: capabilities belong to the agent; identity and
rendering belong to the surface.
The honest cost of that rule: #66's tools ran inside the Channel, which already
knows who spoke, so identity needed no transport. Moving them to the agent puts
a network boundary between "who spoke" and the code that needs to know, and
crossing it needed CopilotKit#6826 — which is why this branch waited on a
release.
@copilotkit/channels@0.9.2carries it.What moved
The agent owns the Composio session, discovery, execution, effect
classification, and the approval decision.
agent/composio_tools/.The Channel keeps exactly two things it alone can do: knowing who pressed a
button, and putting something in front of one person.
Two things got better, not just relocated
One approval gate. A Composio call now pauses on the same
confirm_writecard that already guards a Linear or Notion write. Because the graph resumes
after the decision, the model sees an approved call's result — #66 could not, and
said so in its own comments.
No classification cap. Effects are looked up per slug and cached, instead of
building the whole map up front under a fixed limit. A real slug past that limit
used to be unclassified through no fault of the model.
Identity
The agent reads who spoke from
forwardedProps.channelActorand from nowhereelse. It is never a tool argument the model fills, which is what made agent-side
Composio unsafe before: the model would have been choosing whose mailbox to open.
A caller's own
channel_actoris dropped whichever way it is spelled.A turn that forwards nobody is anonymous, and says so out loud. The key is
written on every run —
Nonewhen nobody was forwarded — because the graph ischeckpointed per thread and leaving it out lets the previous speaker's identity
stand. Two live cases, both reproduced end to end against the real adapter before
being fixed: a second person in a Slack thread had a personal Gmail call execute
in the first person's account, and a regenerated turn replayed the checkpoint's
actor on the managed surface, which is the deployed default. Both are covered by
tests that go red when the identity is wiped.
Sessions key on platform and id together, which fixes a collision carried
over from #66: one deployment serving Slack and Teams would have given
U1oneither platform the same Composio identity, and therefore each other's connected
accounts.
platformis closed to the surfaces that exist, which is also whatmakes the key unambiguous — no member contains a colon, so the first colon is
always the separator.
One deliberate divergence, called out rather than buried. The actor's
kindmust be a person, so a bot or app posting into a thread cannot spend somebody's
connected account.
@copilotkit/channelsdocumentskindas untrusted metadatathat is "useless for deciding what they may do". It is used here only to
narrow access and never to grant it — refusing
bot,app,systemandunknowncan only ever deny, never permit. The user-visible cost is real: anunattributable delivery now gets no reply where it previously drove a full run.
One approval setting, not two
COMPOSIO_APPROVALSused to offerdestructiveandwritesas separate modes.They gated an identical set of calls, and no configuration could have made them
differ — so this branch collapses them into
on, alongsideoff.The tag vocabulary is why. Effects come from Composio's MCP behaviour tags, and
those say exactly two things:
readOnlyHintanddestructiveHint. There is notag for "a write that is definitely not destructive", and
idempotentHintcannotstand in for one, because DELETE is idempotent. A tool the lookup cannot classify
is gated as destructive rather than guessed at, so every call is a read or
destructive, and both modes gated exactly the non-reads.
The rule is now one line: a read goes through unasked, everything else is asked
about. Nothing to change on upgrade —
destructiveandwritesstill parseas
on, because refusing them would fail an existing deployment at boot over avalue that always meant what it still means.
The card fails safe on both sides of the wire.
effectis a closed vocabulary,and a missing or unrecognised value renders as danger rather than neutral. The
MCP-interceptor path that already guards Linear and Notion writes now sends its
own effect too, derived from the annotations it was already reading — otherwise
making the card fail safe would have turned every ordinary Linear write red.
The card renders two ways, never three: danger, or no styling at all. A tool
saying
readOnlyHint: falseis declining to claim it is safe, not claiming it issafe, so an endorsement colour is never the right answer — a review round caught
API-delete-a-blockrendering as a green button labelled "API".writeearnsthe absence of a warning and nothing more, which is exactly what distinguishes it
from
destructive; painting it red too would make every card red and teachapprovers to click through the colour. The dangerous-word test reads every word
of the action, not the first, and the button label falls back to
Confirmwhenthe word that makes an action dangerous is not the word leading it.
The connect flow, and the auth gap it exposed
A connect link is a bearer capability — whoever opens it binds their account to
the id it was minted for. So the card posted in the thread carries no link; the
link is minted on click, for the clicker, and delivered where only they can see
it. When private delivery is not available the link is discarded, never posted
publicly, and the thread gets a sentence saying so.
This works on managed delivery as of
@copilotkit/channels@0.9.2and theIntelligence side of OSS-988. Slack only — Teams has no ephemeral surface, so
a Teams-backed Channel still has nowhere private to put a link.
Minting needs a route on the agent, and that route could not be added safely as
things stood: the runtime has always sent
AGENT_AUTH_HEADERand this servicehas always ignored it. Behind Railway's private domain that was survivable; in
front of a link-minting endpoint it is not. So the secret is now checked, on two
deliberately different rules:
enforcing unconditionally would take every existing deployment down on
upgrade. Health stays open, on
GETandHEAD, with or without a trailingslash; the platform probe cannot send a secret.
than serving. There is no configuration in which handing a capability to an
unauthenticated caller is intended.
Compared with
compare_digeston bytes, not==on text:compare_digestraises on non-ASCII
str, and headers arrive latin-1 decoded, so one accentedcharacter in a wrong secret returned 500 instead of 401 — unauthenticated and
remotely triggerable. The middleware also sits inside CORS now, so preflights are
answered instead of rejected and
CORS_ALLOW_ORIGINSstops being inert.No Slack tokens, and no second ingress
An earlier revision of this branch let the app hold its own Slack bot and app
tokens and attach a direct Slack adapter, because the managed adapter could not
post privately. That is removed. OSS-988 says it plainly: once the private post
ships, OpenTag drops its tokens and its direct adapter and goes back to managed
delivery.
It was not free while it lasted. Two ingress paths meant every message was
received twice and answered twice. Worse, the direct path needs Socket Mode on
the Slack app, and Socket Mode stops Slack delivering events to Intelligence at
all — so the managed path was silently dead from 24 August until this branch
removed the hatch and Socket Mode was turned off. A test now pins the removal:
constructing the Channel must not attach a Slack adapter even when both Slack
variables are set.
setup.mdsays to leave Socket Mode off, and why.Approvals, and who may answer
A call that runs in one person's own account names that person as its approver.
The agent can only say whose call it is; the surface knows who clicked, so the
surface enforces — refusing anyone else on both buttons, telling them privately,
and leaving the graph paused so the right person can still answer. Declining is
enforced too, since a colleague cancelling somebody else's action is the same
problem in reverse.
The refusal is delivered rather than dropped, and the buttons are idempotent.
An approved write that fails now says so in the thread. It did not until
late in this branch's life, and the reason is worth recording: the emit helper
dispatches
copilotkit_manually_emit_message, the LangGraph adapter matches itsown unprefixed name, and the single CUSTOM event that reached the wire was
dropped by both renderers because it was not
on_interrupt. Every local gate wasgreen while nothing was delivered. It is now dispatched under the adapter's own
enum, and the tests assert on what the renderer received — before,
[]; after,the actual message. The same defect had also silenced the "starting the coder"
note, which is fixed with it.
A failure after the graph resumed says "outcome unknown, this may already have
been applied" — and, when the error proves nothing left the card, says that
instead rather than casting doubt on a write that never happened.
The agent only claims tools it has
Two prompt defects, both found by watching it answer in a live workspace, both
of which made it answer from belief instead of looking.
It was told nothing about which connected apps exist, so asked whether it could
see Linear — which was configured — it said no, without ever searching. It now
names the configured apps, shared and personal separated, capped at twelve. It
names apps and never actions: which actions a toolkit exposes is the search
tool's answer.
It was also told unconditionally to prefer Notion, Linear and GitHub sources and
to "use GitHub tools". A deployment where Composio is the only integration
registers none of those, and believing it was already equipped it made zero tool
calls across three consecutive turns. That guidance is now included only when
those tools were actually registered. A prompt naming a tool the agent does not
have is worse than silence: silence makes it look, a false claim makes it guess.
How a late approval still lands in the right account
agent/tests/test_composio_approval_resume.pycovers this. A resume is deliveredas a resume command and carries no forwarded properties, so the actor that
decided whose account a call runs in is not re-sent. An approval clicked twenty
minutes later still lands in the right account because the state schema declares
the actor and the checkpoint keeps it. Wiping the identity turns that test red,
along with three others.
Verification
pnpm check-typesclean.had been green over a package whose own
tsc --noEmitfailed. Both pass now,and CI runs both.
and re-run to confirm a test catches it; the review that produced these fixes
found the previous suites could not. Among them: deleting the whole auth
middleware, swapping
compare_digestfor==, droppingapproverforwarding,inverting
{confirmed}, never resuming, wiping the forwarded actor, deletingthe Composio wiring from the CDK stack, removing
COPY agent/*.pyfrom theDockerfile, dropping the connect notice, and attaching a Slack adapter.
uv buildproduces a wheelwith every module,
docker buildexits 0, and inside the image the projectreports its own version — which it could not before, since there was no
[build-system]and the project was never built.composio>=0.17.0established by downloading every wheel from 0.12 to 0.21 andfinding where
sessionsfirst appears.preserve()in the Railway config verified by compiling the config and diffingit against a graph holding live values: preserved variables produce no change,
an unmanaged one is deleted. Secrets are not dropped.
@copilotkit/channels@0.9.2bump was verified by unpacking the publishedtarballs, not by trusting the version number. It moves the runtime to 1.70.1
and
@ag-ui/clientto 0.0.59 because 0.9.2 depends on the newer@ag-ui/*andtwo copies of
AbstractAgentin one tree fail the typecheck.railwayIaC apply needs credentials and was not run.How this was reviewed
Six review rounds, each one an unbiased panel plus a probe that runs the code
rather than reading it, and each round found real defects in the previous
round's fixes. That is the useful part, so it is recorded rather than smoothed
over:
speaker's account; a regenerated turn replayed the checkpoint's actor; and on
a documented fallback path the actor key was stripped before the graph saw it,
so a named turn wrote the raw actor — name and email — into the checkpoint.
All three were reproduced end to end against the real adapter before being
fixed, and the final round traced the model through the installed adapter and
found it sound.
and a delete that rendered as a safe green button. Both were found by
measuring what reached the consumer, not by reading the diff.
middleware, swapping
compare_digestfor==, droppingapproverforwarding, inverting the confirmation, never resuming, connecting as the
wrong user — each of those once left every test passing. They now fail.
socket to the production Intelligence gateway on every run. Both closed; the
Python suite got a third faster as a result.
The PR description has been corrected three times because it outran the code.
This section is here so the next reader can weigh the claims above against how
they were established.
Not moved
The operator connect script, because it runs outside a turn — it is now
uv run python -m composio_tools.connect_cli <toolkit>, where the session lives.It reads the repo
.env, which it previously did not, so the documented commandnow runs instead of exiting 1.