## Summary
- Fixes SDK custom-tool mapping for custom toolkits that reuse the same
child tool slug, e.g. `APP_A.VERSION` and `APP_B.VERSION`.
- Adds toolkit-qualified lookup (`toolkit + original slug`) while
keeping bare original-slug lookup only when it is unambiguous.
- Throws a client-side ambiguity error for manual
`session.execute('VERSION')` when multiple custom toolkit tools share
`VERSION`, instead of falling through to backend execution.
- Keeps `session.customToolkits()` / `session.customTools()` mapped to
the backend-assigned final slugs for duplicate child names.
## Repro / validation
Validated locally with a temporary Tool Router script (not committed)
using two dummy custom toolkits that both define `VERSION`:
- Current `next` SDK fails before high-level use with `Custom tool slug
collision: original slug "VERSION" maps to multiple final slugs...`.
- Tool Router backend accepts the same payload and returns both
`LOCAL_DUMMY_APP_A_REPRO_VERSION` and `LOCAL_DUMMY_APP_B_REPRO_VERSION`.
- This branch builds the SDK map successfully and high-level SDK
execution routes each final slug to its own internal handler:
- `LOCAL_DUMMY_APP_A_REPRO_VERSION` -> `{ app: "A", version: "1.0.0" }`
- `LOCAL_DUMMY_APP_B_REPRO_VERSION` -> `{ app: "B", version: "2.0.0" }`
## Tests
- `pnpm --filter @composio/core typecheck`
- `pnpm --filter @composio/core exec vitest run
test/models/customTool.test.ts test/models/toolRouter.test.ts
test/models/customToolRouting.test.ts`
## Summary
Follow-up to #3358. The `.transform()` added on the `connectedAccounts`
Zod schema made the inferred TypeScript type resolve to the
**post-transform output** (`Record<string, string[]>`), so TS users
still hit `error TS2322: Type 'string' is not assignable to type
'string[]'` even though the runtime accepted strings. The runtime fix
landed; the public TS surface didn't follow.
This PR moves the coercion out of the schema and into
`ToolRouter.create`, mirroring the Python implementation. The public
type is now `Record<string, string | string[]>` and runtime behavior is
unchanged.
## Why CI didn't catch this in #3358
The PR's own test file (`toolRouter.test.ts`) already assigns a `string`
to `ToolRouterCreateSessionConfig`, but `ts/packages/core/tsconfig.json`
has `"include": ["src/**/*.ts"]` — `test/` is not typechecked. So `tsc
--noEmit clean` was technically true while masking the issue. Verified
empirically by dropping a typecheck file into `src/` on `next`:
```
src/__type_check.ts(8,7): error TS2322: Type 'string' is not assignable to type 'string[]'.
```
After this PR, the same file compiles cleanly.
## Changes
- **`ts/packages/core/src/types/toolRouter.types.ts`**: drop
`.transform()` from the `connectedAccounts` schema; `z.infer` now
exposes `Record<string, string | string[]>`.
- **`ts/packages/core/src/models/ToolRouter.ts`**: coerce `string →
[string]` inside `create` before sending to the backend (which expects
arrays). Preserves the original `connected_accounts: undefined` behavior
when the field is omitted (the parent schema's `.partial()` strips the
`default({})`).
- **`ts/packages/core/src/types/toolRouter.types.ts`**: fix stale
`@param {Record<string, string>}` JSDoc on the same field.
- **`.changeset/v31-connected-accounts-array.md`**: drop the inaccurate
*"Bumps `@composio/client` to `0.1.0-alpha.70`"* line — that bump landed
in a separate commit on `next` (`7c1443838`), not in #3358's diff (`git
diff 2bbd791de^1 2bbd791de` shows zero `pnpm-lock.yaml`/`package.json`
changes).
- **`.changeset/fix-connected-accounts-string-type.md`**: new `patch`
changeset for `@composio/core`.
## Test plan
- [x] `pnpm exec tsc --noEmit -p tsconfig.json` clean on
`@composio/core`
- [x] `pnpm exec vitest run` — 883/883 tests pass (incl. 110 in
`toolRouter.test.ts`)
- [x] `pnpm prettier --check` clean on touched files
- [x] Spot-checked: assigning `{ connectedAccounts: { gmail: 'ca_xxx' }
}` to `ToolRouterCreateSessionConfig` now typechecks; assigning the
array form still typechecks.
The Zod `.transform()` added in #3358 made the inferred type of
`ToolRouterCreateSessionConfig['connectedAccounts']` resolve to
`Record<string, string[]>` (the post-transform output), so TypeScript
users still hit `Type 'string' is not assignable to type 'string[]'`
even though the runtime accepted strings. The runtime fix landed; the
public TS surface didn't follow.
Drop the schema transform and coerce inside `ToolRouter.create` instead,
mirroring the Python implementation. The public type is now
`Record<string, string | string[]>` and behavior is unchanged.
While here:
- fix the JSDoc on `connectedAccounts` (was `Record<string, string>`)
- drop the inaccurate `@composio/client 0.1.0-alpha.70` bump line from
the v31-connected-accounts-array changeset (this PR didn't perform
that bump; it landed in a separate commit on `next`)
Same pattern as ToolRouterCreateSessionConfigSchema — own Zod schema
with no defaults and nullable clearing fields. No longer references
create config types that have incompatible defaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Description
Apollo (hermes#9550) added a new \`REVOKED\` value to the
connected-account status enum, alongside the existing \`INITIALIZING |
INITIATED | ACTIVE | FAILED | EXPIRED | INACTIVE\`. The Apollo
public-API response schema is \`z.nativeEnum(ConnectionStatusEnum)\`, so
any list / get / status-update endpoint can now return \`REVOKED\` and
clients must handle it.
This PR fixes every hand-written place in the TS and Python SDKs that
hard-coded the prior 6-value set and would either (a) fail-fast against
\`REVOKED\` (Zod / Effect Schema parse error) or (b) silently
misclassify it.
## TypeScript SDK — BREAKING fixes
| File | Change | Severity |
|---|---|---|
| \`ts/packages/core/src/types/connectedAccounts.types.ts\` | Add
\`REVOKED\` to \`ConnectedAccountStatuses\` and
\`ConnectedAccountStatusSchema\` (\`z.enum\`) | BREAKING —
\`z.enum().parse()\` would throw on responses containing the new value |
| \`ts/packages/cli/src/models/connected-accounts.ts\` | Add \`REVOKED\`
to Effect \`Schema.Literal(...)\` for \`ConnectedAccountItem.status\` |
BREAKING — \`Schema.Literal()\` enforces literal matching |
| \`ts/packages/cli/src/services/tool-router-session-connections.ts\` |
Add \`'REVOKED'\` case to the \`normalizeConnectedAccountStatus\` switch
| BREAKING — silent fall-through to \`'INACTIVE'\` masked the new status
|
| \`ts/packages/cli/src/services/composio-clients.ts\` | Replace the
explicit 6-value cast with a generic that resolves to whatever the
Stainless client currently accepts | Forwards \`REVOKED\` to Apollo;
auto-tracks future Stainless regenerations |
| \`ts/packages/core/src/models/ConnectedAccounts.ts\` | Cast
\`statuses\` to the Stainless params type so \`REVOKED\` (already
accepted by Apollo) flows through until \`@composio/client\` is
regenerated | Adapter shim; remove once \`@composio/client\` is
regenerated |
## Python SDK — DEGRADED fixes
| File | Change | Severity |
|---|---|---|
| \`python/composio/core/models/webhook_events.py\` | Add \`REVOKED\` to
\`ConnectionStatusEnum\` | DEGRADED — code that checked \`status in
ConnectionStatusEnum\` would have failed to recognise the new value |
| \`python/composio/core/models/connected_accounts.py\` |
\`ConnectionRequest.wait_for_connection\` now fails fast on terminal
non-ACTIVE states (\`FAILED\` / \`EXPIRED\` / \`INACTIVE\` /
\`REVOKED\`) instead of looping until the 60s timeout | DEGRADED —
REVOKED used to look identical to "still pending" until the timeout
fired |
The Stainless-generated \`@composio/client\` (TS) and
\`composio_client\` (Python) packages will pick up \`REVOKED\`
automatically when regenerated; this PR covers the hand-written SDK code
only.
# How did I test this PR
- \`pnpm typecheck\` (root) — clean (\`9 successful, 9 total\`)
- \`pnpm test\` (root) — 694 passed, 1 skipped (75 test files); examples
validation \`Validated 22 example packages\`
- Python: \`ruff check\` and \`ruff format --check\` clean on both
edited files
- Smoke-tested Python imports + enum membership:
\`\`\`
PYTHON: imports OK, REVOKED present: REVOKED
\`\`\`
Triggered by: sarthak@composio.dev | Source: web
Session:
https://zen-api-production-4c98.up.railway.app/dashboard/#/chat/zen-14dc825802da
Also makes multiAccount fields optional in ToolRouterUpdateSessionConfig
(PATCH semantics) and adds a PATCH-safe transform that doesn't inject
create-time defaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three commands (`connected-accounts.list`, `connections.list`,
`connections.remove`) carried verbatim copies of the same 13-line
`Effect.catchTag('ParseError', ...)` forward-compat guard. Future
changes to the warning message or fallback semantics would have to
touch all three sites in lockstep.
Lift the guard into `src/effects/decode-connected-account-list.ts` so
each call site collapses to a single `yield*`. The helper carries the
WHY (forward-compat with Apollo enum additions) and the safety
justification for the cast in one place.
Surfaced by Cursor Bugbot on commit afb33deef.
Custom tools are set at create time only — PATCH can't change them,
so recomputing the preloaded slugs list after update is dead code.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Stainless client already validates SessionPatchParams. Replace the
ToolRouterUpdateSessionConfigSchema with a plain interface and coerce
connectedAccounts string→string[] in the transform layer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix stale preloadedCustomToolSlugs after update by recomputing them
- Add ToolRouterUpdateSessionConfigSchema for runtime validation
- Add PATCH-safe transform variants that don't inject defaults
- Add 11 unit tests for transformToolRouterUpdateParams
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exposes the existing PATCH /api/v3/tool_router/session/{id} endpoint
through the SDK so users can modify session config after creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Net −56 lines of comment prose. Earlier commits added multi-paragraph
blocks explaining each forward-compat guard, sentinel choice, and
schema-bypass cast — most of that is rederivable from the surrounding
code. Keep one-line WHY notes where the intent is non-obvious; drop
the prose elsewhere.
The `as ConnectedAccountListResponse` cast in the `catchTag` fallback
of three commands (`connected-accounts.list.cmd.ts`,
`connections.list.cmd.ts`, `connections.remove.cmd.ts`) lies about the
schema brand to render a payload that just failed `decodeUnknown`. The
existing comment block explained _why we degrade_, but not _why the
cast is safe enough_.
Add a one-line per-site note recording that downstream renderers /
selectors only read whitelisted, non-credential-bearing fields
(`id`, `alias`, `word_id`, `toolkit.slug`, `status`, ...) — `state`
and `data` are never indexed, so an unvalidated raw payload cannot
leak tokens through this path.
Surfaced by kieran-typescript review of PR #3344 (P3#3).
The same OAuth2-only / S2S_OAUTH2-next scope was documented in two
adjacent comment blocks: above `Oauth2RevokedConnectionDataSchema` and
above `S2SOauth2ConnectionDataSchema`. Keep the canonical one on the
S2S_OAUTH2 schema (where it's load-bearing — the arm there is the
preemptive one) and reduce the OAuth2 comment to a one-line
cross-reference.
Surfaced by code-simplicity review of PR #3344 (P3#2).
`connections.list.cmd.ts` and `connections.remove.cmd.ts` decode the
same `ConnectedAccountListResponse` schema as
`connected-accounts.list.cmd.ts` but were yielding the decode without
a `catchTag` — a future server-side status addition would brick those
commands for every user, including users not filtering by status.
Mirror the forward-compat guard from `connected-accounts.list.cmd.ts`:
catch `ParseError`, warn pointing at `composio upgrade`, fall back to
the raw response.
Surfaced by pattern-recognition review of PR #3344 (P2#4) — keeping
the guard scoped to one command of three would reproduce the same
bug Apollo's next enum addition (queued S2S_OAUTH2 revocation) is
about to cause.
`resolveConnectedAccountSelection<T extends SelectableConnectedAccount>`
returned `T | undefined` to preserve the input narrowing, but no caller
relies on the narrowed type — `listen.cmd.ts:141` and
`tools.execute.cmd.ts:947` both feed the result through generic Pick
shapes. Drop the type parameter and take/return the wider type
directly.
Addresses code-simplicity review of PR #3344 (P2#3).
The earlier change to `tool-router-session-connections.ts` lifted the
synchronous map body into `Effect.flatMap(... Effect.gen ...)` purely
to host one `yield* Effect.logDebug(...)` call. Restore the simpler
`Effect.map` shape and use `Effect.tap` to emit the diagnostic — the
inner block stays purely synchronous, mirroring how peer services in
the same directory structure handle map-then-log flows.
Also adds a one-line comment on line 122 noting that the second pass
over `items` (the raw, pre-normalization shape) is intentional —
`auth_config` and `is_disabled` metadata are dropped from the
normalized projection.
Net: −15 lines, fewer nesting levels, easier diff to read.
Addresses code-simplicity review of PR #3344 (P2#2).
`tool-router-session-connections.ts` is a service-layer module — peer
services (`composio-clients.ts`, `project-context.ts`,
`env-lang-detector.ts`, `js-package-manager-detector.ts`) all route
diagnostic signal through `Effect.logDebug`, while user-facing warnings
are emitted via `ui.log.warn` from command layers. The previous
`Effect.logWarning` was a CLI outlier; align with the service-layer
convention.
Addresses pattern-recognition review of PR #3344 (P2#1).
Adds the test coverage flagged in PR #3344 review item P1#5:
TS (`ts/packages/core/test/connectedAccounts/connectedAccountAuthStates.test.ts`):
- `Oauth2RevokedConnectionDataSchema` parses with and without optional
`revoked_at`
- `ConnectionDataSchema` discriminated union accepts REVOKED for both
OAUTH2 and S2S_OAUTH2 (the preempted Apollo arm from P1#1)
- `transformConnectedAccountResponse` does not silently drop `state`
for REVOKED on either auth scheme
- Closure test on the `ConnectionStatuses` enum so a future Apollo
addition trips a CI failure on purpose
TS CLI (`connected-accounts.list.cmd.test.ts`):
- Server returning an unrecognized status no longer crashes the
command — the `catchTag('ParseError')` fallback warns the user to
run `composio upgrade` and renders the row anyway
Python (`tests/test_connected_accounts.py`):
- `wait_for_connection` fails fast on FAILED / EXPIRED / REVOKED
(parameterized) — the contract added by this PR
- `wait_for_connection` does NOT treat INACTIVE as terminal, so a
connection briefly transiting through INACTIVE still recovers to
ACTIVE — guards against the regression called out in P1#2
`normalizeConnectedAccountStatus` was coercing any value outside the
known status set to `'INACTIVE'`. That misled selection helpers like
`resolveDefaultConnectedAccountsByToolkit`, since `'INACTIVE'` carries
a specific meaning ("user-disabled"). When Apollo introduces a new
status (REVOKED was added in PR #9550; S2S_OAUTH2 revocation is
queued), every account with that status would silently be tagged as
user-disabled rather than honestly labeled as unrecognized.
Introduce a `'UNKNOWN'` sentinel exposed via a new
`SelectableConnectedAccount` type (a CLI-internal widening of
`ConnectedAccountItem` that the selection helpers accept). Selection
still picks only `'ACTIVE'` accounts, so any other label is filtered
out; `'UNKNOWN'` rows are not selectable, but the original server
value is logged via `Effect.logWarning` so users notice and can
upgrade.
Addresses PR #3344 review comment P2#8.
Replace the conditional-type incantation
`Parameters<typeof client.connectedAccounts.list>[0] extends infer P
? P extends { statuses?: infer S } ? S : never : never`
at two call sites with a direct cast to
`ConnectedAccountListParams['statuses']`, which `@composio/client`
exports for exactly this purpose. ConnectedAccounts.ts:114 was already
using the shorter form.
Two issues with the previous form: (a) the comment claimed it "widens"
the type, but `as` cannot add values, so the framing was misleading;
(b) if Stainless ever renames or moves the `statuses` field, the
conditional silently resolves to `never` and swallows the cast — the
direct member access fails loudly at the typecheck instead.
Trim the duplicated multi-line comments to one line per call site.
Addresses PR #3344 review comment P2#7.
`Schema.decodeUnknown(ConnectedAccountListResponse)` was yielded
without a `catchTag`, so any new server-side connection status
(REVOKED was added in Apollo PR #9550, S2S_OAUTH2 revocation is queued
next) would brick `composio dev connected-accounts list` for the
entire user base — even users only filtering on `--status ACTIVE`.
Catch `ParseError`, log a warning pointing at `composio upgrade`, and
fall back to the raw server response so the table still renders.
Addresses PR #3344 review comment P1#4.
The top-level ConnectedAccountStatusSchema accepts REVOKED for any auth
scheme, but only Oauth2ConnectionDataSchema had a matching arm. For the
12 other unions, a REVOKED payload would fail discriminated-union
parsing and `transformConnectedAccountResponse` would silently drop the
entire `state` object — refresh tokens / api keys / oauth tokens would
vanish without surfacing an error.
Today this can't fire (per Apollo, REVOKED is OAuth2-only) but
`S2S_OAUTH2` is on the near-term roadmap. Add the REVOKED arm
preemptively and an inline invariant comment so the next reviewer
doesn't have to re-derive the OAuth2-only scope.
Addresses PR #3344 review comment P1#1.
Codex round 2 caught that the top-level `ConnectedAccountStatusSchema`
already accepted REVOKED, but the nested `state.val.status` validation
went through `ConnectionStatuses` in `connectedAccountAuthStates.types.ts`
which still only knew the prior 6 values. As a result:
- `transformConnectedAccountResponse` would silently drop the `state` of
any REVOKED account (the discriminated union has no REVOKED variant).
- `webhookEvents.types.ts` (which uses `z.nativeEnum(ConnectionStatuses)`
for `data.status`) would reject any webhook payload whose top-level
account status is REVOKED.
Fix:
- Add REVOKED to the `ConnectionStatuses` enum (transitively fixes the
webhook schema via `nativeEnum`).
- Add `Oauth2RevokedConnectionDataSchema` mirroring the Apollo-side
schema at `connectionDataScheme.ts` (shape = INITIATING + literal
REVOKED status + optional `revoked_at` timestamp), wire it into the
`Oauth2ConnectionDataSchema` discriminated union, and export the
inferred type. Apollo's PR 9550 only added a REVOKED variant to the
OAuth2 scheme; other auth schemes (ApiKey / Basic / Bearer / etc.) do
not produce REVOKED at runtime so they don't need a new variant.
Codex review of zen/connection-status-revoked-118z84 flagged two TypeScript
paths that did not yet handle the new REVOKED status:
- `ConnectionRequest.waitForConnection` only treated FAILED and EXPIRED
as terminal error states, so a revoked connection would poll until the
full timeout instead of failing immediately. Add REVOKED to
`terminalErrorStates`. (This is the TS analog of the Python
`wait_for_connection` fix already in this PR.)
- The `composio dev connected-accounts list --status` flag's
`Options.choice` whitelist did not include 'REVOKED', so users could
not filter for revoked accounts. Add it. The Stainless-typed list call
underneath needs the same cast workaround used in
`services/composio-clients.ts` so REVOKED forwards through until
@composio/client is regenerated.
Apollo (hermes#9550) added REVOKED to the connected-account status enum.
The SDK had several places that hard-coded the prior 6 values and would
either fail-fast against the new value (BREAKING) or silently misclassify
it (DEGRADED).
TypeScript SDK (BREAKING fixes):
- Add REVOKED to `ConnectedAccountStatusSchema` / `ConnectedAccountStatuses`
so `z.enum().parse(...)` no longer throws on responses that include the
new status.
- Add REVOKED to the CLI's Effect `Schema.Literal(...)` for
`ConnectedAccountItem.status`.
- Add REVOKED to the `normalizeConnectedAccountStatus` switch so a
REVOKED account is no longer silently coerced to INACTIVE.
- Fix the connected-account-list raw-cast to forward REVOKED into the
Stainless-generated client (the cast widens to whatever
`client.connectedAccounts.list` accepts so it auto-tracks regenerations).
Python SDK (DEGRADED fixes):
- Add REVOKED to `ConnectionStatusEnum` so consumers checking
`event.data.status in ConnectionStatusEnum` recognise the value.
- Update `ConnectionRequest.wait_for_connection()` to fail fast on
terminal non-ACTIVE statuses (FAILED / EXPIRED / INACTIVE / REVOKED)
instead of polling until the 60s timeout.
The Stainless-generated `@composio/client` and `composio_client` packages
will pick up the new enum value automatically when regenerated; this PR
covers the hand-written SDK code only.
Non-breaking: existing callers passing a single string per toolkit
continue to work. The string is silently coerced to a single-element
array to match the v3.1 wire format.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove conditional spreads/checks — undefined properties are stripped
during JSON serialization anyway.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- TS SDK: connectedAccounts schema changed from Record<string, string>
to Record<string, string[]> matching @composio/client alpha.70
- Fixed toolRouter.test.ts to use array format
- Docs: added "Attaching custom tools to an existing session" section
to custom-tools-and-toolkits.mdx showing composio.use() with customs
- Docs: updated all connectedAccounts examples to array format
- Docs: updated users-and-sessions to note custom tools can be attached
to existing sessions via .use()
- Docs: removed TypeScript-only qualifier for custom tools
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bumps @composio/client to 0.1.0-alpha.70 which changes
connected_accounts from Dict[str, str] to Dict[str, List[str]].
Updated both TS and Python SDK types to match.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The TS use() was missing these two metadata fields that create() passes
to ToolRouterSession. Without them:
- Custom tools with preload:true wouldn't appear in session.tools()
- Inline custom tool schemas wouldn't be re-injected on execute/search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
composio.use(id, { customTools, customToolkits }) now POSTs to
/api/v3.1/tool_router/session/{id}/attach with inline experimental.custom_*
when customs are provided, hydrating the session and binding the local
tools map. Without options it keeps using the v3 retrieve endpoint.
Also caches the original CustomTool[] / CustomToolkit[] arrays on
CustomToolsMap so the SDK has the data needed to re-inject inline customs
on subsequent v3.1 search/execute/execute_meta calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>