feat(composio): move Composio into the agent, behind one approval gate - #68
Draft
mxmzb wants to merge 6 commits into
Draft
feat(composio): move Composio into the agent, behind one approval gate#68mxmzb wants to merge 6 commits into
mxmzb wants to merge 6 commits into
Conversation
…agent First step of moving Composio out of the Channel and into the agent, so the agent has the capability on its own and one approval mechanism covers every gated action instead of two. Nothing is wired into the graph yet. This is the part that needs no identity and no network: the environment contract, which identities a turn acts as, the effect classification, and a per-identity session cache. Carried over deliberately, because each was paid for once already: - A toolkit named in both toolkit lists resolves to the personal scope only, unconditionally. Picking whichever session loaded first would attribute an action to a person or to the shared account depending on restart order, and an unidentified turn must get no access rather than falling through to the shared account. - An unclassified tool is a write, and an empty tag list is unclassified. - One unreachable identity is logged and dropped, not raised: a broken personal account must not take the team's shared toolkits down for the turn. New here, from reading the Python SDK rather than assuming parity with the TypeScript one: `sandbox` and `workbench` are separate keyword arguments, the latter a deprecated alias, and passing both raises. The sandbox — a remote shell and a remote Python tool, on by default — is disabled explicitly, since the SDK only defaults it off under a session preset we do not use. The package is `composio_tools`, not `composio`: this directory is on the agent's import path and would otherwise shadow the SDK. Both packaging tests now derive their expectations from what is on disk. The wheel assertion listed its packages by hand, and the image assertion checked only the coding package — either would have let a new package pass locally and crash the container on first import.
Registers `search_my_tools` and `run_my_tool` on the graph. The agent now has the capability on its own — the point of the move — and a person's own apps resolve to their own account without the Channel deciding anything. Identity is read per call from the actor the Channel forwards, so one tool registration serves everybody. The model chooses what to do; the platform decides whose account it happens in. It is never a tool argument the model fills, which is what made this unsafe to put in the agent before. `channel_actor` is declared on the state schema whether or not Composio is configured. The AG-UI adapter drops a forwarded key the schema does not name, so leaving it conditional would make "who spoke" depend on an unrelated feature flag. Verified against the adapter: an ordinary run is mode "start" and carries forwarded properties every turn, so the value cannot go stale when a second person speaks in the same thread — the adapter only treats a run as a continuation when the caller supplies a node name, which a Channel never does. Ported decisions worth keeping visible: - Discovery round-robins across scopes instead of concatenating them. The cap is global and scopes arrive shared-first, so concatenating answers "what's on my calendar" with five Linear tools. - A slug is placed by its toolkit prefix. Without that, a slug discovery never returned falls to the first scope — the shared account, which does not carry the toolkit at all — and a personal slug would run as the team on an anonymous turn. - `execute` reports a failed tool in `error` and does not raise, so that field is checked. A try/except alone reads every failed write as a success. - Only an explicit false connection status asks somebody to connect. An absent status is silence. A malformed forwarded actor reads as an anonymous turn. The value crosses a process boundary, and refusing personal access is the safe failure. The health test asserting an unconfigured agent exposes no tools was passing for the wrong reason: the repo `.env` is loaded at import, so an optional feature configured on a developer's machine leaked into it. It now clears the variable, and a sibling test covers the configured case.
…ites A Composio call that needs signing off now pauses on `confirm_write` — the same card, the same pause, and the same resume that already gate a Linear or Notion mutation. That removes the second approval system this repo had and documented: one gate for every action a person has to answer for. Two things improve rather than merely move: - The graph resumes after the decision, so the model sees an approved call's result. The channel-side version could not, because it had no graph to resume, and said so in its own comments. - Effects are resolved one slug at a time 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; now the only unclassified slug is one that does not exist. An unclassifiable slug is destructive, not a write. `writes` mode gates both, but `destructive` — the default — gates only the first, so calling an unrecognised slug a write would run it unapproved in the mode most deployments ship with. That answer is deliberately not cached: a lookup that failed for a transient reason deserves another chance, and being wrong costs one prompt. A personal call names its approver, because approving it spends that person's access and nobody else's. The agent can only say whose call it is; the surface knows who clicked, so it enforces. That enforcement lands with the channel-side change. The test that matters here is the delayed approval. A resume is delivered as a resume command and carries no forwarded properties, so the actor that decided whose account the call runs in is not re-sent. It survives because the state schema declares it and the checkpoint keeps it — verified by removing that declaration and watching the personal call stop running.
…me sends A connect link is a bearer capability: whoever opens it binds an account to the Composio user id the link was minted for. So the agent grows one route that mints a link for one person and one app, and the surface calls it — because the surface is what knows who clicked, and private delivery is the one thing an agent cannot do. The model never sees a URL. That route cannot be added safely as things stood. The runtime has always sent `AGENT_AUTH_HEADER` as its `Authorization` and this service has always ignored it. Behind Railway's private domain that was survivable; in front of a capability-minting endpoint it is not. So the secret is now checked, on two different rules: - Ordinary traffic is checked only when a secret is configured. A local run has none, and enforcing unconditionally would take every existing deployment down on upgrade. Health stays open either way; the platform probe cannot send one. - The connect route requires a secret of its own accord. With none configured it reports itself unavailable rather than serving. There is no configuration in which handing connect links to unauthenticated callers is intended. Compared with `compare_digest`, not `==`: an early-exit comparison leaks the length of the matching prefix, and this value is the only thing in front of the agent. Also fixes an identity collision carried over from the channel-side version: a Composio session was keyed on the raw provider id, so one deployment serving Slack and Teams would give `U1` on either platform the same Composio identity, and therefore each other's connected accounts. Sessions now key on platform and id together, which is what the approver field already did. Anyone who connected an account against the old key reconnects — only test workspaces, since that version never merged. A shared toolkit is refused a click-minted link rather than handled: it runs as one workspace identity, so a link minted for a clicker connects an account no shared call ever uses. The graph and the route share one runtime object. Two session caches would mean two sessions per identity, and one process holding one session is the reason this moved into the agent at all.
…ature Completes the move. The agent asks the surface to post a Connect card; the card carries no link; whoever presses it gets one minted for them and delivered where only they can see it. That split is the whole architecture in one flow — the capability is the agent's, knowing who clicked and reaching one person is the surface's. The connect request travels as an interrupt that is resumed immediately, because it is a request to draw something rather than a decision to wait on. Connecting takes minutes and several people in one thread may each connect their own account, which is not a shape one paused graph can hold. Approver enforcement lands here too, since only the surface knows who pressed a button. A card for a call in someone's own account refuses anybody else, tells them privately, and leaves the graph paused so the right person can still answer — including on the decline button, which would otherwise let a colleague cancel somebody else's action. Matching is on platform and id together, so a person who shares an id on another platform is not the same person. A shared toolkit still cannot be connected from Slack, and now cannot be connected from the dashboard either without noticing: the operator path is `uv run python -m composio_tools.connect_cli <toolkit>`, which lives where the session does. It refuses a personal toolkit for the same reason the route does. The connect button needs direct Slack delivery, so `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` return — both or neither. The managed adapter reports `supportsEphemeral: false`, and delivery deliberately does not fall back to a DM: a bearer capability must not follow somebody somewhere it was not scoped to. Documentation covers the feature end to end for the first time — the variables on the service that actually reads them, the two-step-per-app setup, why a slug typo is silent, the difference between a shared account and a personal one, and why `AGENT_AUTH_HEADER` is required before a link can be minted. Both deployments carry the variables: Railway per service, and on AWS the toolkit lists as CDK context with the api key and the shared secret as secret fields. Gates run: `pnpm check-types` clean, 261 TypeScript tests, 271 Python tests, 11 CDK tests. `railway` IaC validation needs credentials and was not run.
mxmzb
marked this pull request as draft
September 1, 2026 22:25
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.
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.
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.channelActor, added inCopilotKit#6826. 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.
Sessions key on platform and id together. That fixes a collision carried over
from #66, which keyed on the raw provider id — one deployment serving Slack and
Teams would have given
U1on either platform the same Composio identity, andtherefore each other's connected accounts.
A malformed or absent actor reads as an anonymous turn: shared toolkits still
work, personal ones are refused. Refusing personal access is the safe failure.
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.
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; the platform probe cannot send one.
than serving. There is no configuration in which handing a capability to an
unauthenticated caller is intended.
Compared with
compare_digest, not==.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 it
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 test worth reading
agent/tests/test_composio_approval_resume.py. A resume is delivered as a resumecommand 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 — verified by removing that declaration and watching the
personal call stop running.
Requires a released
@copilotkit/channelsPersonal toolkits depend on CopilotKit#6826 shipping. On an older pin the actor
never arrives, every turn reads as anonymous, and personal toolkits silently
offer nothing while shared ones keep working.
setup.mdsays so where somebodydebugging would look.
Verification
pnpm check-typesclean.disabled sandbox, the toolkit-prefix routing, the mandatory
errorcheck, thefail-closed capability rule, the approver enforcement, and the resume identity
were each broken deliberately to confirm a test catches it.
would have let a new package pass locally and crash the container on first
import.
railwayIaC validation needs credentials and was not run.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.