diff --git a/app/src/components/composio/toolkitMeta.tsx b/app/src/components/composio/toolkitMeta.tsx
index 93d824b84..13cda81a1 100644
--- a/app/src/components/composio/toolkitMeta.tsx
+++ b/app/src/components/composio/toolkitMeta.tsx
@@ -14,6 +14,7 @@
import { type ReactNode, useState } from 'react';
import { canonicalizeComposioToolkitSlug } from '../../lib/composio/toolkitSlug';
+import type { ComposioToolkitCatalogEntry } from '../../lib/composio/types';
import type { SkillCategory } from '../skills/skillCategories';
export interface ComposioToolkitMeta {
@@ -251,9 +252,18 @@ function GenericIntegrationIcon() {
);
}
-function ComposioLogoBadge({ slug, name }: { slug: string; name: string }) {
+function ComposioLogoBadge({
+ slug,
+ name,
+ logoUrl: logoOverride,
+}: {
+ slug: string;
+ name: string;
+ /** Prefer the dynamic-catalog logo when present; fall back to the CDN URL. */
+ logoUrl?: string;
+}) {
const [failed, setFailed] = useState(false);
- const logoUrl = composioLogoUrl(slug);
+ const logoUrl = logoOverride || composioLogoUrl(slug);
if (failed) {
return ;
@@ -285,6 +295,42 @@ function guessCategory(slug: string, name: string): SkillCategory {
return 'Tools & Automation';
}
+/**
+ * Map Composio's catalog category strings onto our fixed `SkillCategory`
+ * buckets. Composio category names are free-form (e.g. `"productivity"`,
+ * `"crm"`, `"developer-tools"`), so we match on substrings and return the
+ * first hit. Returns `undefined` when nothing matches so the caller can
+ * fall back to the slug/name keyword heuristic.
+ */
+function mapComposioCategory(categories?: string[]): SkillCategory | undefined {
+ if (!categories || categories.length === 0) return undefined;
+ const haystack = categories.join(' ').toLowerCase();
+ const has = (...needles: string[]) => needles.some(n => haystack.includes(n));
+
+ if (has('chat', 'messaging', 'communication')) return 'Chat';
+ if (has('social', 'marketing')) return 'Social';
+ if (
+ has(
+ 'productivity',
+ 'document',
+ 'calendar',
+ 'scheduling',
+ 'project management',
+ 'project-management',
+ 'note',
+ 'task',
+ 'storage',
+ 'email'
+ )
+ ) {
+ return 'Productivity';
+ }
+ if (has('crm', 'developer', 'devtool', 'analytics', 'payment', 'finance', 'database', 'cloud')) {
+ return 'Platform';
+ }
+ return undefined;
+}
+
function defaultDescription(name: string, category: SkillCategory): string {
switch (category) {
case 'Chat':
@@ -342,17 +388,32 @@ function descriptionForToolkit(key: string, name: string, category: SkillCategor
return defaultDescription(name, category);
}
-export function composioToolkitMeta(slug: string): ComposioToolkitMeta {
+/**
+ * Build the render model for a toolkit card/modal.
+ *
+ * When a live-catalog `entry` is supplied (backend dynamic catalog — see
+ * `COMPOSIO_DYNAMIC_CATALOG_PLAN.md`) its name/description/category/logo
+ * win, so the UI reflects what Composio actually offers. Every field
+ * degrades gracefully to the local hardcoded derivation when the entry is
+ * absent (older core/backend) or a given field is empty.
+ */
+export function composioToolkitMeta(
+ slug: string,
+ entry?: ComposioToolkitCatalogEntry
+): ComposioToolkitMeta {
const key = canonicalizeComposioToolkitSlug(slug);
- const name = MANAGED_TOOLKIT_NAME_BY_SLUG.get(key) ?? prettifyUnknownSlug(key);
- const category = guessCategory(key, name);
+ const name =
+ entry?.name?.trim() || MANAGED_TOOLKIT_NAME_BY_SLUG.get(key) || prettifyUnknownSlug(key);
+ const category = mapComposioCategory(entry?.categories) ?? guessCategory(key, name);
+ const description = entry?.description?.trim() || descriptionForToolkit(key, name, category);
+ const logoUrl = entry?.logo?.trim() || composioLogoUrl(key);
return {
slug: key,
name,
- description: descriptionForToolkit(key, name, category),
+ description,
category,
- icon: ,
- logoUrl: composioLogoUrl(key),
+ icon: ,
+ logoUrl,
permissionLabel: permissionLabelFor(category),
};
}
diff --git a/app/src/lib/composio/catalogCache.test.ts b/app/src/lib/composio/catalogCache.test.ts
new file mode 100644
index 000000000..a1e1e0c80
--- /dev/null
+++ b/app/src/lib/composio/catalogCache.test.ts
@@ -0,0 +1,100 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { getToolkitCatalog, invalidateToolkitCatalogCache } from './catalogCache';
+
+const mockListToolkits = vi.fn();
+
+vi.mock('./composioApi', () => ({ listToolkits: () => mockListToolkits() }));
+
+describe('catalogCache', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ window.localStorage.clear();
+ invalidateToolkitCatalogCache();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('fetches once then serves the cache within the TTL', async () => {
+ mockListToolkits.mockResolvedValue({ toolkits: ['gmail'], catalog: [] });
+
+ const first = await getToolkitCatalog();
+ const second = await getToolkitCatalog();
+
+ expect(first.toolkits).toEqual(['gmail']);
+ expect(second.toolkits).toEqual(['gmail']);
+ expect(mockListToolkits).toHaveBeenCalledTimes(1);
+ });
+
+ it('dedupes concurrent callers into a single fetch', async () => {
+ let resolve: (v: unknown) => void = () => {};
+ mockListToolkits.mockReturnValue(
+ new Promise(r => {
+ resolve = r;
+ })
+ );
+
+ const a = getToolkitCatalog();
+ const b = getToolkitCatalog();
+ resolve({ toolkits: ['github'], catalog: [] });
+
+ await Promise.all([a, b]);
+ expect(mockListToolkits).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-fetches after the cache is invalidated', async () => {
+ mockListToolkits.mockResolvedValue({ toolkits: ['gmail'], catalog: [] });
+
+ await getToolkitCatalog();
+ invalidateToolkitCatalogCache();
+ await getToolkitCatalog();
+
+ expect(mockListToolkits).toHaveBeenCalledTimes(2);
+ });
+
+ it('serves a stale cache when a refresh fails', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ mockListToolkits.mockResolvedValue({ toolkits: ['gmail'], catalog: [] });
+ await getToolkitCatalog();
+
+ // Advance past the 24h TTL and make the refresh fail.
+ vi.setSystemTime(new Date('2026-01-03T00:00:00Z'));
+ mockListToolkits.mockRejectedValue(new Error('backend down'));
+
+ const result = await getToolkitCatalog();
+ expect(result.toolkits).toEqual(['gmail']); // stale, but better than erroring
+ });
+
+ it('propagates the error on a cold-cache failure', async () => {
+ mockListToolkits.mockRejectedValue(new Error('backend down'));
+ await expect(getToolkitCatalog()).rejects.toThrow('backend down');
+ });
+
+ it('does not cache a response whose fetch was invalidated mid-flight', async () => {
+ // First fetch is in flight when the Composio client identity switches
+ // (mode toggle / BYO key → composio:config-changed → invalidate).
+ let resolveFirst: (v: unknown) => void = () => {};
+ mockListToolkits.mockReturnValueOnce(
+ new Promise(r => {
+ resolveFirst = r;
+ })
+ );
+
+ const inflightCall = getToolkitCatalog();
+ invalidateToolkitCatalogCache(); // mid-flight: previous tenant's response is now stale
+ resolveFirst({ toolkits: ['old_tenant'], catalog: [] });
+ await inflightCall; // the original caller still receives its response
+
+ // The stale response must NOT have been written as a fresh 24h cache, so
+ // the next read issues a fresh RPC and serves the new tenant's catalog.
+ mockListToolkits.mockResolvedValueOnce({ toolkits: ['new_tenant'], catalog: [] });
+ const next = await getToolkitCatalog();
+
+ expect(next.toolkits).toEqual(['new_tenant']);
+ expect(mockListToolkits).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/app/src/lib/composio/catalogCache.ts b/app/src/lib/composio/catalogCache.ts
new file mode 100644
index 000000000..3232ebe52
--- /dev/null
+++ b/app/src/lib/composio/catalogCache.ts
@@ -0,0 +1,143 @@
+/**
+ * Client-side cache for the Composio toolkit catalog.
+ *
+ * Mirrors the backend's 24h cache (see the workspace
+ * `COMPOSIO_DYNAMIC_CATALOG_PLAN.md`). The catalog only changes when
+ * Composio adds/removes toolkits, so re-fetching it on every Skills-page
+ * mount is wasteful. We layer two guards in front of `listToolkits()`:
+ *
+ * 1. **In-flight dedupe** — N components mounting at once share a single
+ * RPC instead of firing one each. Race-free because the JS event loop
+ * never interleaves the synchronous check-then-assign below.
+ * 2. **localStorage TTL (24h)** — survives reloads and serves instantly
+ * on a warm cache; falls back to a live fetch when stale/absent.
+ *
+ * `invalidateToolkitCatalogCache()` clears both tiers — call it when the
+ * Composio client identity changes (backend ↔ direct mode, BYO API key),
+ * exactly like the existing `composio:config-changed` refresh path.
+ */
+import { listToolkits } from './composioApi';
+import type { ComposioToolkitsResponse } from './types';
+
+const CACHE_KEY = 'composio:catalog:v1';
+const TTL_MS = 24 * 60 * 60 * 1000;
+
+interface CachedCatalog {
+ fetchedAt: number;
+ response: ComposioToolkitsResponse;
+}
+
+/** Module-level in-flight promise so concurrent callers join one fetch. */
+let inflight: Promise | null = null;
+/** In-memory mirror so we avoid a JSON.parse on the hot path. */
+let memory: CachedCatalog | null = null;
+/**
+ * Bumped on every invalidation. A fetch captures the generation at start and
+ * refuses to write its result if the generation has since changed — so a
+ * response that was already in flight when the Composio client identity
+ * switched (mode toggle / BYO key → `composio:config-changed`) can't poison
+ * the cache with the *previous* tenant's catalog and have it served as fresh
+ * for 24h.
+ */
+let generation = 0;
+
+function readPersisted(): CachedCatalog | null {
+ if (memory) return memory;
+ try {
+ const raw = window.localStorage.getItem(CACHE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as CachedCatalog;
+ if (
+ !parsed ||
+ typeof parsed.fetchedAt !== 'number' ||
+ !parsed.response ||
+ !Array.isArray(parsed.response.toolkits)
+ ) {
+ return null;
+ }
+ memory = parsed;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+function writePersisted(response: ComposioToolkitsResponse): void {
+ const entry: CachedCatalog = { fetchedAt: Date.now(), response };
+ memory = entry;
+ try {
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify(entry));
+ } catch {
+ // Private-mode / quota errors are non-fatal — the in-memory mirror
+ // still serves this session.
+ }
+}
+
+function isFresh(entry: CachedCatalog | null): boolean {
+ return entry !== null && Date.now() - entry.fetchedAt < TTL_MS;
+}
+
+/**
+ * Resolve the toolkit catalog, preferring a fresh client cache.
+ *
+ * - Fresh cache (< 24h) → returned immediately, no RPC.
+ * - Stale-but-present + fetch ok → fresh value, cache refreshed.
+ * - Stale-but-present + fetch fail → stale value served (graceful degrade).
+ * - Cold + fetch fail → error propagates to the caller.
+ */
+export async function getToolkitCatalog(): Promise {
+ const cached = readPersisted();
+ if (cached && isFresh(cached)) return cached.response;
+
+ if (inflight) return inflight;
+ // Snapshot the generation so a mid-flight invalidation makes this fetch's
+ // result non-authoritative (see `generation`).
+ const startGeneration = generation;
+ const fetchPromise = listToolkits()
+ .then(response => {
+ // Only cache the response if no invalidation happened while it was in
+ // flight; otherwise it belongs to a stale tenant. Still return it to
+ // this caller — just don't poison the shared cache for future reads.
+ if (generation === startGeneration) {
+ writePersisted(response);
+ } else {
+ console.debug('[composio-cache] discarding catalog response invalidated mid-flight');
+ }
+ return response;
+ })
+ .catch(err => {
+ // On failure, fall back to a stale cache if we have one rather than
+ // forcing the UI into an error state for a list that rarely changes.
+ // Skip the fallback if we were invalidated mid-flight — the cached
+ // value belongs to the previous tenant.
+ if (cached && generation === startGeneration) {
+ console.warn(
+ '[composio-cache] catalog fetch failed; serving stale cache:',
+ err instanceof Error ? err.message : String(err)
+ );
+ return cached.response;
+ }
+ throw err;
+ })
+ .finally(() => {
+ // Only clear the slot if it's still ours — an invalidation may have
+ // reset `inflight` to null and a newer fetch taken its place.
+ if (inflight === fetchPromise) {
+ inflight = null;
+ }
+ });
+ inflight = fetchPromise;
+ return fetchPromise;
+}
+
+/** Drop both cache tiers so the next read re-fetches. */
+export function invalidateToolkitCatalogCache(): void {
+ generation += 1;
+ memory = null;
+ inflight = null;
+ try {
+ window.localStorage.removeItem(CACHE_KEY);
+ } catch {
+ // ignore
+ }
+}
diff --git a/app/src/lib/composio/hooks.test.ts b/app/src/lib/composio/hooks.test.ts
index 139131234..c35fdb71b 100644
--- a/app/src/lib/composio/hooks.test.ts
+++ b/app/src/lib/composio/hooks.test.ts
@@ -29,6 +29,9 @@ describe('useComposioIntegrations', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
+ // The toolkit catalog is now cached in localStorage (24h TTL); clear it
+ // so each test exercises the mocked fetch instead of a prior test's cache.
+ window.localStorage.clear();
sessionToken = 'jwt-abc';
mockOpenhumanComposioGetMode.mockResolvedValue({
result: { mode: 'backend', api_key_set: true },
@@ -54,6 +57,46 @@ describe('useComposioIntegrations', () => {
expect(result.current.error).toBe('backend connection listing failed');
});
+ it('exposes the dynamic catalog keyed by canonical slug', async () => {
+ const { useComposioIntegrations } = await import('./hooks');
+
+ mockListToolkits.mockResolvedValue({
+ toolkits: ['gmail', 'googlecalendar'],
+ catalog: [
+ { slug: 'gmail', name: 'Gmail', logo: 'https://x/gmail.png', enabled: true },
+ // Alias slug must be canonicalized to googlecalendar.
+ { slug: 'google_calendar', name: 'Google Calendar', enabled: true },
+ ],
+ });
+ mockListConnections.mockResolvedValue({ connections: [] });
+
+ const { result } = renderHook(() => useComposioIntegrations(0));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.catalogByToolkit.get('gmail')?.name).toBe('Gmail');
+ expect(result.current.catalogByToolkit.get('gmail')?.logo).toBe('https://x/gmail.png');
+ expect(result.current.catalogByToolkit.get('googlecalendar')?.name).toBe('Google Calendar');
+ });
+
+ it('leaves the catalog empty when the core omits it (back-compat)', async () => {
+ const { useComposioIntegrations } = await import('./hooks');
+
+ mockListToolkits.mockResolvedValue({ toolkits: ['gmail'] });
+ mockListConnections.mockResolvedValue({ connections: [] });
+
+ const { result } = renderHook(() => useComposioIntegrations(0));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.toolkits).toEqual(['gmail']);
+ expect(result.current.catalogByToolkit.size).toBe(0);
+ });
+
it('groups connections by toolkit, sorts by status then createdAt', async () => {
const { useComposioIntegrations } = await import('./hooks');
diff --git a/app/src/lib/composio/hooks.ts b/app/src/lib/composio/hooks.ts
index 7967dbecd..87d1f10f6 100644
--- a/app/src/lib/composio/hooks.ts
+++ b/app/src/lib/composio/hooks.ts
@@ -3,15 +3,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isLocalSessionToken } from '../../utils/localSession';
import { openhumanComposioGetMode } from '../../utils/tauriCommands';
import { getCoreStateSnapshot } from '../coreState/store';
-import { listAgentReadyToolkits, listConnections, listToolkits } from './composioApi';
+import { getToolkitCatalog, invalidateToolkitCatalogCache } from './catalogCache';
+import { listAgentReadyToolkits, listConnections } from './composioApi';
import { canonicalizeComposioToolkitSlug } from './toolkitSlug';
-import type { ComposioConnection } from './types';
+import type { ComposioConnection, ComposioToolkitCatalogEntry } from './types';
// ── useComposioIntegrations ───────────────────────────────────────
export interface UseComposioIntegrationsResult {
/** Toolkit slugs enabled on the backend allowlist. */
toolkits: string[];
+ /**
+ * Live Composio catalog entries (dynamic name/logo/description/
+ * categories) keyed by canonical lowercased slug. Empty when the
+ * core/backend predates the dynamic catalog — consumers then fall
+ * back to the local `toolkitMeta` derivation.
+ */
+ catalogByToolkit: Map;
/** Best (highest-status) connection keyed by lowercased toolkit slug. */
connectionByToolkit: Map;
/** All connections keyed by lowercased toolkit slug, sorted by status (ACTIVE first, then by createdAt). */
@@ -40,6 +48,7 @@ export interface UseComposioIntegrationsResult {
export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioIntegrationsResult {
const isLocalSession = isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken);
const [toolkits, setToolkits] = useState([]);
+ const [catalog, setCatalog] = useState([]);
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -80,6 +89,7 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
if (!enabled) {
if (mountedRef.current) {
setToolkits([]);
+ setCatalog([]);
setConnections([]);
setError(null);
setLoading(false);
@@ -90,13 +100,14 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
let nextError: string | null = null;
try {
const [toolkitsResult, connectionsResult] = await Promise.allSettled([
- listToolkits(),
+ getToolkitCatalog(),
listConnections(),
]);
if (!mountedRef.current) return;
if (toolkitsResult.status === 'fulfilled') {
setToolkits(toolkitsResult.value.toolkits ?? []);
+ setCatalog(toolkitsResult.value.catalog ?? []);
} else {
const message =
toolkitsResult.reason instanceof Error
@@ -155,6 +166,10 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
useEffect(() => {
const onConfigChanged = () => {
console.debug('[composio-cache] window:composio:config-changed → refresh()');
+ // The Composio client identity changed (backend ↔ direct / BYO key),
+ // so the cached catalog belongs to the previous tenant. Drop it before
+ // refetching, mirroring the core-side ComposioConfigChanged eviction.
+ invalidateToolkitCatalogCache();
if (isLocalSession) {
void resolveFetchEnabled().then(enabled => {
if (enabled) {
@@ -163,6 +178,7 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
}
if (mountedRef.current) {
setToolkits([]);
+ setCatalog([]);
setConnections([]);
setError(null);
setLoading(false);
@@ -184,6 +200,14 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
return 0;
};
+ const catalogByToolkit = useMemo(() => {
+ const map = new Map();
+ for (const entry of catalog) {
+ map.set(canonicalizeComposioToolkitSlug(entry.slug), entry);
+ }
+ return map;
+ }, [catalog]);
+
const connectionByToolkit = useMemo(() => {
const map = new Map();
for (const conn of connections) {
@@ -215,7 +239,15 @@ export function useComposioIntegrations(pollIntervalMs = 5_000): UseComposioInte
return map;
}, [connections]);
- return { toolkits, connectionByToolkit, connectionsByToolkit, loading, error, refresh };
+ return {
+ toolkits,
+ catalogByToolkit,
+ connectionByToolkit,
+ connectionsByToolkit,
+ loading,
+ error,
+ refresh,
+ };
}
// ── useAgentReadyComposioToolkits ─────────────────────────────────
diff --git a/app/src/lib/composio/types.ts b/app/src/lib/composio/types.ts
index 0f09b6467..bb0323a23 100644
--- a/app/src/lib/composio/types.ts
+++ b/app/src/lib/composio/types.ts
@@ -5,8 +5,43 @@
* backend emits camelCase, snake_case where the Rust RPC layer does).
*/
+/**
+ * One toolkit as described by the live Composio catalog. The backend
+ * fetches this from `composio.toolkits.get({})` (see the workspace
+ * `COMPOSIO_DYNAMIC_CATALOG_PLAN.md`), caches it, and forwards it through
+ * the core so the desktop UI no longer has to hardcode display metadata.
+ *
+ * Everything except `slug` is best-effort: older cores/backends that
+ * predate the dynamic catalog omit `catalog` entirely, so consumers must
+ * fall back to the local `toolkitMeta` derivation.
+ */
+export interface ComposioToolkitCatalogEntry {
+ /** Toolkit slug as Composio emits it, e.g. `"googlecalendar"`. */
+ slug: string;
+ /** Human-readable name, e.g. `"Google Calendar"`. */
+ name: string;
+ /** Composio-hosted logo URL (`meta.logo`). */
+ logo?: string;
+ /** Short description (`meta.description`). */
+ description?: string;
+ /** Composio category names (`meta.categories[].name`/slug). */
+ categories?: string[];
+ /**
+ * Whether the user can actually connect/use this toolkit right now —
+ * i.e. it passed the backend curation/allowlist gate. Uncurated
+ * toolkits still appear in the catalog but render as preview.
+ */
+ enabled?: boolean;
+}
+
export interface ComposioToolkitsResponse {
+ /** Back-compat: slugs the user is allowed to connect (enabled only). */
toolkits: string[];
+ /**
+ * Rich render model from the dynamic Composio catalog. Optional —
+ * cores that predate the dynamic catalog send only `toolkits`.
+ */
+ catalog?: ComposioToolkitCatalogEntry[];
}
/**
diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx
index 22098915b..1b4ada909 100644
--- a/app/src/pages/Skills.tsx
+++ b/app/src/pages/Skills.tsx
@@ -462,6 +462,9 @@ export default function Skills() {
const {
toolkits: composioToolkits,
+ // Default to an empty map so the component is resilient when a test
+ // mock (or an older hook build) omits the dynamic-catalog field.
+ catalogByToolkit: composioCatalogByToolkit = new Map(),
connectionByToolkit: composioConnectionByToolkit,
connectionsByToolkit: composioConnectionsByToolkit,
error: composioError,
@@ -538,21 +541,34 @@ export default function Skills() {
const composioCatalogToolkits = useMemo(() => {
const normalizedToolkits = composioToolkits.map(slug => canonicalizeComposioToolkitSlug(slug));
- const missingKnownToolkits = KNOWN_COMPOSIO_TOOLKITS.filter(
- slug => !normalizedToolkits.includes(slug)
- );
- if (IS_DEV && missingKnownToolkits.length > 0) {
- console.debug('[skills][composio] filling gaps from KNOWN_COMPOSIO_TOOLKITS', {
+ // Prefer the live Composio catalog when the backend supplies it. The
+ // hardcoded KNOWN_COMPOSIO_TOOLKITS list only fills gaps for older
+ // cores that predate the dynamic catalog (see
+ // COMPOSIO_DYNAMIC_CATALOG_PLAN.md) so the grid is never empty.
+ const dynamicSlugs = Array.from(composioCatalogByToolkit.keys());
+ const hasDynamicCatalog = dynamicSlugs.length > 0;
+ const baseSlugs = hasDynamicCatalog ? dynamicSlugs : KNOWN_COMPOSIO_TOOLKITS;
+
+ if (IS_DEV) {
+ const missingKnownToolkits = KNOWN_COMPOSIO_TOOLKITS.filter(
+ slug => !normalizedToolkits.includes(slug)
+ );
+ console.debug('[skills][composio] building catalog', {
+ source: hasDynamicCatalog ? 'dynamic-backend' : 'hardcoded-fallback',
+ dynamicCount: dynamicSlugs.length,
toolkitCount: composioToolkits.length,
connectionCount: composioConnectionByToolkit.size,
hasError: Boolean(composioError),
- missingKnownToolkits,
+ missingKnownToolkits: hasDynamicCatalog ? [] : missingKnownToolkits,
});
}
- return Array.from(new Set([...KNOWN_COMPOSIO_TOOLKITS, ...normalizedToolkits])).sort((a, b) =>
+
+ // Union base slugs with enabled slugs and any connected toolkit so a
+ // connection always renders even if it's missing from the catalog.
+ return Array.from(new Set([...baseSlugs, ...normalizedToolkits])).sort((a, b) =>
a.localeCompare(b)
);
- }, [composioToolkits, composioConnectionByToolkit, composioError]);
+ }, [composioToolkits, composioCatalogByToolkit, composioConnectionByToolkit, composioError]);
// Unified item list
const allItems: SkillItem[] = useMemo(() => {
@@ -600,12 +616,14 @@ export default function Skills() {
connection: ComposioConnection | undefined;
}> = [];
for (const slug of composioCatalogToolkits) {
- const meta = composioToolkitMeta(slug);
+ const canonical = canonicalizeComposioToolkitSlug(slug);
+ const entry = composioCatalogByToolkit.get(canonical);
+ const meta = composioToolkitMeta(slug, entry);
const connection = composioConnectionByToolkit.get(meta.slug);
entries.push({ meta, connection });
}
return entries;
- }, [composioCatalogToolkits, composioConnectionByToolkit]);
+ }, [composioCatalogToolkits, composioCatalogByToolkit, composioConnectionByToolkit]);
const composioFilteredEntries = useMemo(() => {
const q = searchQuery.toLowerCase();
diff --git a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx
index c47e0fbd7..c13a3040c 100644
--- a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx
+++ b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx
@@ -8,6 +8,7 @@ import Skills from '../Skills';
let composioRefresh = vi.fn();
let composioError: string | null = null;
let composioToolkits: string[] = [];
+let composioCatalogByToolkit = new Map();
let composioConnectionByToolkit = new Map();
let composioConnectionsByToolkitOverride: Map | null = null;
let sessionToken = 'jwt-abc';
@@ -37,6 +38,7 @@ vi.mock('../../lib/skills/hooks', () => ({
vi.mock('../../lib/composio/hooks', () => ({
useComposioIntegrations: () => ({
toolkits: composioToolkits,
+ catalogByToolkit: composioCatalogByToolkit,
connectionByToolkit: composioConnectionByToolkit,
connectionsByToolkit:
composioConnectionsByToolkitOverride ??
@@ -71,6 +73,7 @@ describe('Skills page — Composio catalog fallback', () => {
composioRefresh = vi.fn();
composioError = null;
composioToolkits = [];
+ composioCatalogByToolkit = new Map();
composioConnectionByToolkit = new Map();
composioConnectionsByToolkitOverride = null;
sessionToken = 'jwt-abc';
@@ -107,6 +110,38 @@ describe('Skills page — Composio catalog fallback', () => {
expect(screen.queryByRole('heading', { name: 'Other' })).not.toBeInTheDocument();
});
+ it('renders integrations from the live dynamic catalog when the backend provides one', () => {
+ // When the backend ships the dynamic catalog, the grid is sourced from
+ // it (names/categories from the entry), not the hardcoded fallback list.
+ composioCatalogByToolkit = new Map([
+ [
+ 'acme_crm',
+ { slug: 'acme_crm', name: 'Acme CRM Dynamic', categories: ['crm'], enabled: true },
+ ],
+ [
+ 'gmail',
+ { slug: 'gmail', name: 'Gmail Dynamic', categories: ['productivity'], enabled: true },
+ ],
+ ]);
+
+ renderWithProviders(, { initialEntries: ['/connections'] });
+ openAppsTab();
+
+ const integrationsSection = screen.getByTestId('composio-integrations-card');
+ // Names come from the dynamic catalog entry, overriding local metadata.
+ expect(
+ within(integrationsSection as HTMLElement).getByText('Acme CRM Dynamic')
+ ).toBeInTheDocument();
+ expect(
+ within(integrationsSection as HTMLElement).getByText('Gmail Dynamic')
+ ).toBeInTheDocument();
+ // A hardcoded-only toolkit absent from the dynamic catalog must NOT
+ // appear — proving the grid is driven by the backend, not KNOWN_*.
+ expect(
+ within(integrationsSection as HTMLElement).queryByText('Discord')
+ ).not.toBeInTheDocument();
+ });
+
it('shows a stale/error state instead of disconnected toolkits when composio loading fails', () => {
composioError = 'Backend unavailable';
diff --git a/src/openhuman/composio/connected_integrations.rs b/src/openhuman/composio/connected_integrations.rs
index d18902f8f..695fd4fc7 100644
--- a/src/openhuman/composio/connected_integrations.rs
+++ b/src/openhuman/composio/connected_integrations.rs
@@ -415,11 +415,70 @@ pub async fn fetch_connected_integrations_status(
///
/// Returns `None` when we couldn't even build a client (no auth),
/// signalling the caller should NOT cache this result.
+/// The connectable toolkit slugs to surface in the agent prompt, aligned
+/// with the backend's execution gate.
+///
+/// Prefers the dynamic catalog's **enabled** entries (openhuman PR #3933 /
+/// backend #1012). The backend gate (`isToolkitConnectable` →
+/// `getProjectList().filter(p => p.enabled)`) and `catalog[].enabled` are
+/// driven by the same project auth-config status, so sourcing membership from
+/// `catalog.filter(enabled)` pins the prompt's advertised set to exactly what
+/// connect/authorize/execute will actually allow — it can't drift even if the
+/// backend later changes how the flat `toolkits` array is projected.
+///
+/// Falls back to the back-compat `toolkits` array when the catalog is absent
+/// (backends predating the dynamic catalog send only `toolkits`); without the
+/// fallback, membership against an old core would collapse to empty and the
+/// agent would lose every integration. Disabled catalog entries are dropped
+/// because the gate would reject them — advertising them would only invite
+/// failed delegations. Slugs are trimmed + lowercased to match downstream
+/// canonicalisation.
+fn connectable_toolkit_slugs(
+ toolkits: &[String],
+ catalog: &[super::types::ComposioToolkitCatalogEntry],
+) -> Vec {
+ let normalize = |s: &str| s.trim().to_ascii_lowercase();
+ if catalog.is_empty() {
+ toolkits
+ .iter()
+ .map(|t| normalize(t))
+ .filter(|t| !t.is_empty())
+ .collect()
+ } else {
+ catalog
+ .iter()
+ .filter(|entry| entry.enabled.unwrap_or(false))
+ .map(|entry| normalize(&entry.slug))
+ .filter(|slug| !slug.is_empty())
+ .collect()
+ }
+}
+
+/// Choose the one-line description rendered for a toolkit in the agent
+/// prompt's `## Connected Integrations` block.
+///
+/// Prefers the backend's **dynamic catalog** description (openhuman PR #3933
+/// / backend #1012 — `GET /agent-integrations/composio/toolkits` now returns
+/// a `catalog[]` with per-toolkit metadata) so the orchestrator advertises
+/// what Composio actually offers. Falls back to the hardcoded
+/// `toolkit_description` table when the catalog omits the toolkit or ships an
+/// empty description — i.e. older backends that predate the dynamic catalog,
+/// or project toolkits whose Composio metadata join produced no blurb. Keyed
+/// by lowercased slug to match the canonicalised allowlist.
+fn resolve_toolkit_description(
+ catalog_descriptions: &std::collections::HashMap,
+ slug: &str,
+) -> String {
+ catalog_descriptions
+ .get(slug)
+ .cloned()
+ .unwrap_or_else(|| super::providers::toolkit_description(slug).to_string())
+}
+
async fn fetch_connected_integrations_uncached(
config: &Config,
) -> Option> {
use super::client::{create_composio_client, direct_list_connections, ComposioClientKind};
- use super::providers::toolkit_description;
// Route via the mode-aware factory so the chat-agent's
// "connected_integrations" view reflects the live tenant — backend
@@ -456,19 +515,52 @@ async fn fetch_connected_integrations_uncached(
// integration from the orchestrator until the process restarts or
// the cache is explicitly invalidated — a single 5xx during
// startup would silently break delegation for the whole session.
- let (allowlisted_toolkits, connections, tools_by_toolkit): (
+ let (allowlisted_toolkits, connections, tools_by_toolkit, catalog_descriptions): (
Vec,
Vec,
Vec,
+ std::collections::HashMap,
) = match &kind {
ComposioClientKind::Backend(client) => {
- let allowlist: Vec = match client.list_toolkits().await {
- Ok(resp) => resp
- .toolkits
- .into_iter()
- .map(|toolkit| toolkit.trim().to_ascii_lowercase())
- .filter(|toolkit| !toolkit.is_empty())
- .collect(),
+ let (allowlist, catalog_descriptions): (
+ Vec,
+ std::collections::HashMap,
+ ) = match client.list_toolkits().await {
+ Ok(resp) => {
+ // Index the dynamic catalog's descriptions by lowercased
+ // slug so the prompt builder can prefer them over the
+ // hardcoded table (see `resolve_toolkit_description`).
+ // Empty descriptions are skipped so the fallback applies.
+ let catalog_descriptions: std::collections::HashMap = resp
+ .catalog
+ .iter()
+ .filter_map(|entry| {
+ let slug = entry.slug.trim().to_ascii_lowercase();
+ if slug.is_empty() {
+ return None;
+ }
+ let desc = entry
+ .description
+ .as_deref()
+ .map(str::trim)
+ .filter(|d| !d.is_empty())?;
+ Some((slug, desc.to_string()))
+ })
+ .collect();
+ // Source the connectable set from the catalog's enabled
+ // entries (the gate's own predicate), falling back to the
+ // flat `toolkits` array for pre-catalog backends. See
+ // `connectable_toolkit_slugs`.
+ let allowlist = connectable_toolkit_slugs(&resp.toolkits, &resp.catalog);
+ tracing::debug!(
+ catalog_entries = resp.catalog.len(),
+ catalog_descriptions = catalog_descriptions.len(),
+ connectable = allowlist.len(),
+ catalog_sourced = !resp.catalog.is_empty(),
+ "[composio] fetch_connected_integrations: resolved connectable set from catalog"
+ );
+ (allowlist, catalog_descriptions)
+ }
Err(e) => {
tracing::warn!(
"[composio] fetch_connected_integrations: list_toolkits (backend) failed: {e}"
@@ -524,7 +616,7 @@ async fn fetch_connected_integrations_uncached(
}
};
- (allowlist, connections, tools)
+ (allowlist, connections, tools, catalog_descriptions)
}
ComposioClientKind::Direct(direct) => {
// Direct mode: walk the user's personal Composio tenant
@@ -605,7 +697,14 @@ async fn fetch_connected_integrations_uncached(
Vec::new()
}
};
- (allowlist, connections, tools)
+ // Direct mode has no central catalog endpoint, so prompt
+ // descriptions come from the hardcoded fallback table.
+ (
+ allowlist,
+ connections,
+ tools,
+ std::collections::HashMap::new(),
+ )
}
};
@@ -821,7 +920,7 @@ async fn fetch_connected_integrations_uncached(
integrations.push(ConnectedIntegration {
toolkit: slug.clone(),
- description: toolkit_description(slug).to_string(),
+ description: resolve_toolkit_description(&catalog_descriptions, slug),
tools,
gated_tools,
connected,
@@ -918,3 +1017,98 @@ pub async fn fetch_toolkit_actions(
);
Ok(actions)
}
+
+#[cfg(test)]
+mod connectable_slug_tests {
+ use super::connectable_toolkit_slugs;
+ use crate::openhuman::composio::types::ComposioToolkitCatalogEntry;
+
+ fn entry(slug: &str, enabled: bool) -> ComposioToolkitCatalogEntry {
+ ComposioToolkitCatalogEntry {
+ slug: slug.to_string(),
+ enabled: Some(enabled),
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn prefers_catalog_enabled_entries_and_drops_disabled() {
+ // Disabled entries are excluded (the gate would reject them); the
+ // flat `toolkits` array is ignored when a catalog is present.
+ let catalog = vec![
+ entry("gmail", true),
+ entry("notion", false),
+ entry("GitHub", true), // uppercase slug normalised
+ ];
+ let toolkits = vec!["ignored_when_catalog_present".to_string()];
+ assert_eq!(
+ connectable_toolkit_slugs(&toolkits, &catalog),
+ vec!["gmail".to_string(), "github".to_string()]
+ );
+ }
+
+ #[test]
+ fn falls_back_to_toolkits_when_catalog_empty() {
+ // Older backends send only `toolkits`; membership must still resolve.
+ let toolkits = vec!["Gmail".to_string(), " ".to_string()];
+ assert_eq!(
+ connectable_toolkit_slugs(&toolkits, &[]),
+ vec!["gmail".to_string()]
+ );
+ }
+
+ #[test]
+ fn enabled_none_is_treated_as_not_connectable() {
+ // Defensive: an entry without an explicit `enabled` is excluded,
+ // matching the gate's strict `enabled === true` check.
+ let catalog = vec![ComposioToolkitCatalogEntry {
+ slug: "mystery".to_string(),
+ enabled: None,
+ ..Default::default()
+ }];
+ assert!(connectable_toolkit_slugs(&[], &catalog).is_empty());
+ }
+}
+
+#[cfg(test)]
+mod catalog_description_tests {
+ use super::resolve_toolkit_description;
+ use std::collections::HashMap;
+
+ #[test]
+ fn prefers_dynamic_catalog_description_when_present() {
+ let mut catalog = HashMap::new();
+ catalog.insert(
+ "gmail".to_string(),
+ "Live Gmail blurb from the Composio catalog".to_string(),
+ );
+ assert_eq!(
+ resolve_toolkit_description(&catalog, "gmail"),
+ "Live Gmail blurb from the Composio catalog"
+ );
+ }
+
+ #[test]
+ fn falls_back_to_hardcoded_table_when_catalog_omits_toolkit() {
+ // Empty catalog (older backend / no metadata) → hardcoded fallback.
+ let catalog = HashMap::new();
+ let got = resolve_toolkit_description(&catalog, "gmail");
+ assert_eq!(
+ got,
+ crate::openhuman::composio::providers::toolkit_description("gmail")
+ );
+ assert!(!got.is_empty());
+ }
+
+ #[test]
+ fn falls_back_when_a_different_toolkit_is_catalogued() {
+ // Catalog has an entry, but not for the slug we're rendering — the
+ // fallback must still apply per-slug, not globally.
+ let mut catalog = HashMap::new();
+ catalog.insert("notion".to_string(), "Notion from catalog".to_string());
+ assert_eq!(
+ resolve_toolkit_description(&catalog, "gmail"),
+ crate::openhuman::composio::providers::toolkit_description("gmail")
+ );
+ }
+}
diff --git a/src/openhuman/composio/types.rs b/src/openhuman/composio/types.rs
index 3da35db38..21249f217 100644
--- a/src/openhuman/composio/types.rs
+++ b/src/openhuman/composio/types.rs
@@ -63,12 +63,45 @@ fn de_opt_string_or_object<'de, D: Deserializer<'de>>(d: D) -> Result