Skip to content

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

Merged
tylerslaton merged 4 commits into
mainfrom
feat/composio-in-agent
Sep 8, 2026
Merged

feat(composio): move Composio into the agent, behind one approval gate#68
tylerslaton merged 4 commits into
mainfrom
feat/composio-in-agent

Conversation

@mxmzb

@mxmzb mxmzb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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.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.

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.2 carries 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_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 and from nowhere
else. 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_actor is 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 — None when nobody was forwarded — because the graph is
checkpointed 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 U1 on
either platform the same Composio identity, and therefore each other's connected
accounts. platform is closed to the surfaces that exist, which is also what
makes 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 kind
must be a person, so a bot or app posting into a thread cannot spend somebody's
connected account. @copilotkit/channels documents kind as untrusted metadata
that is "useless for deciding what they may do". It is used here only to
narrow access and never to grant it — refusing bot, app, system and
unknown can only ever deny, never permit. The user-visible cost is real: an
unattributable delivery now gets no reply where it previously drove a full run.

One approval setting, not two

COMPOSIO_APPROVALS used to offer destructive and writes as separate modes.
They gated an identical set of calls, and no configuration could have made them
differ — so this branch collapses them into on, alongside off.

The tag vocabulary is why. Effects come from Composio's MCP behaviour tags, and
those say exactly two things: readOnlyHint and destructiveHint. There is no
tag for "a write that is definitely not destructive", and idempotentHint cannot
stand 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 upgradedestructive and writes still parse
as on, because refusing them would fail an existing deployment at boot over a
value that always meant what it still means.

The card fails safe on both sides of the wire. effect is 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: false is declining to claim it is safe, not claiming it is
safe, so an endorsement colour is never the right answer — a review round caught
API-delete-a-block rendering as a green button labelled "API". write earns
the 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 teach
approvers 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 Confirm when
the 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.2 and the
Intelligence 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_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, on GET and HEAD, with or without a trailing
    slash; the platform probe cannot send a secret.
  • 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 on bytes, not == on text: compare_digest
raises on non-ASCII str, and headers arrive latin-1 decoded, so one accented
character 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_ORIGINS stops 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.md says 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 its
own 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 was
green 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.py covers this. 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. Wiping the identity turns that test red,
along with three others.

Verification

  • pnpm check-types clean.
  • 411 TypeScript, 515 Python, 18 CDK tests — all passing.
  • The CDK package now compiles. Its test runner strips types, so its suite
    had been green over a package whose own tsc --noEmit failed. Both pass now,
    and CI runs both.
  • Mutation-checked, not just written. Every fix in this branch was reverted
    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_digest for ==, dropping approver forwarding,
    inverting {confirmed}, never resuming, wiping the forwarded actor, deleting
    the Composio wiring from the CDK stack, removing COPY agent/*.py from the
    Dockerfile, dropping the connect notice, and attaching a Slack adapter.
  • Packaging is proved by building, not by asserting: uv build produces a wheel
    with every module, docker build exits 0, and inside the image the project
    reports its own version — which it could not before, since there was no
    [build-system] and the project was never built.
  • composio>=0.17.0 established by downloading every wheel from 0.12 to 0.21 and
    finding where sessions first appears.
  • preserve() in the Railway config verified by compiling the config and diffing
    it against a graph holding live values: preserved variables produce no change,
    an unmanaged one is deleted. Secrets are not dropped.
  • The @copilotkit/channels@0.9.2 bump was verified by unpacking the published
    tarballs, not by trusting the version number. It moves the runtime to 1.70.1
    and @ag-ui/client to 0.0.59 because 0.9.2 depends on the newer @ag-ui/* and
    two copies of AbstractAgent in one tree fail the typecheck.
  • railway IaC 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:

  • Identity was got wrong three times. A second speaker's turn ran in the first
    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.
  • Two fixes that were reported as done did not work. The failure notice above,
    and a delete that rendered as a safe green button. Both were found by
    measuring what reached the consumer, not by reading the diff.
  • The suites could not have caught most of this. Deleting the whole auth
    middleware, swapping compare_digest for ==, dropping approver
    forwarding, inverting the confirmation, never resuming, connecting as the
    wrong user — each of those once left every test passing. They now fail.
  • Some tests were reaching the network with real credentials, and one opened a
    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 command
now runs instead of exiting 1.

@mxmzb
mxmzb marked this pull request as draft September 1, 2026 22:25
@mxmzb
mxmzb marked this pull request as ready for review September 4, 2026 15:51
tylerslaton and others added 4 commits September 8, 2026 08:50
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
tylerslaton force-pushed the feat/composio-in-agent branch from 697f270 to 27c5b2e Compare September 8, 2026 15:52
@tylerslaton
tylerslaton merged commit d5ea8cf into main Sep 8, 2026
3 checks passed
@tylerslaton
tylerslaton deleted the feat/composio-in-agent branch September 8, 2026 20:58
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.

2 participants