Skip to content

feat(composio): move Composio into the agent, behind one approval gate - #68

Draft
mxmzb wants to merge 6 commits into
mainfrom
feat/composio-in-agent
Draft

feat(composio): move Composio into the agent, behind one approval gate#68
mxmzb wants to merge 6 commits into
mainfrom
feat/composio-in-agent

Conversation

@mxmzb

@mxmzb mxmzb commented Sep 1, 2026

Copy link
Copy Markdown

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.md on that
branch 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_write
card 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 in
CopilotKit#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 U1 on either platform the same Composio identity, and
therefore 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_HEADER and this service
has 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:

  • Ordinary traffic, 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; the platform probe cannot send one.
  • The connect route, always. With no secret it reports itself unavailable rather
    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 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 — verified by removing that declaration and watching the
personal call stop running.

Requires a released @copilotkit/channels

Personal 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.md says so where somebody
debugging would look.

Verification

  • pnpm check-types clean.
  • 261 TypeScript tests, 271 Python tests, 11 CDK tests — all passing.
  • Mutation-checked, not just written: the unconditional personal-only dedup, the
    disabled sandbox, the toolkit-prefix routing, the mandatory error check, the
    fail-closed capability rule, the approver enforcement, and the resume identity
    were each broken deliberately to confirm a test catches it.
  • Both packaging tests now derive from disk instead of hardcoded lists — either
    would have let a new package pass locally and crash the container on first
    import.
  • railway IaC 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.

…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
mxmzb marked this pull request as draft September 1, 2026 22:25
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