diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx
index 754a21f89..799322cc9 100644
--- a/app/src/components/composio/ComposioConnectModal.tsx
+++ b/app/src/components/composio/ComposioConnectModal.tsx
@@ -157,6 +157,8 @@ interface ComposioConnectModalProps {
toolkit: ComposioToolkitMeta;
/** Existing connection (if any) from the hook. */
connection?: ComposioConnection;
+ /** Connected, but not yet exposed to the agent tool surface. */
+ agentUnsupported?: boolean;
/** Invoked on successful connect/disconnect so the parent can refresh. */
onChanged?: () => void;
onClose: () => void;
@@ -168,6 +170,7 @@ const POLL_TIMEOUT_MS = 5 * 60 * 1_000;
export default function ComposioConnectModal({
toolkit,
connection,
+ agentUnsupported = false,
onChanged,
onClose,
}: ComposioConnectModalProps) {
@@ -724,6 +727,17 @@ export default function ComposioConnectModal({
)}
+ {agentUnsupported && (
+
+
+
+ {t('composio.previewBadge')}
+
+
+ {t('composio.previewTooltip')}
+
+
+ )}
void;
onRetryGlobal: () => void;
@@ -141,15 +142,20 @@ function ComposioConnectorTile({
meta,
connection,
hasComposioError,
+ agentUnsupported,
testId,
onOpen,
onRetryGlobal,
}: ComposioConnectorTileProps) {
const { t } = useT();
- const state = hasComposioError ? 'error' : deriveComposioState(connection);
+ const rawState = deriveComposioState(connection);
+ const state = hasComposioError ? 'error' : rawState;
+ const isPreview = !hasComposioError && agentUnsupported && rawState === 'connected';
const statusLabel = hasComposioError
? t('composio.statusUnavailable')
- : composioStatusLabel(connection, t);
+ : isPreview
+ ? t('composio.previewBadge')
+ : composioStatusLabel(connection, t);
const ctaLabel = hasComposioError
? t('common.retry')
: state === 'connected'
@@ -162,7 +168,7 @@ function ComposioConnectorTile({
? t('common.retry')
: t('skills.connect');
- const isConnected = state === 'connected';
+ const isConnected = state === 'connected' && !isPreview;
const isPending = state === 'pending';
const isExpired = state === 'expired';
const isError = state === 'error' || hasComposioError;
@@ -180,17 +186,27 @@ function ComposioConnectorTile({
type="button"
data-testid={testId}
onClick={handleClick}
- title={`${meta.name} — ${meta.description}`}
+ title={`${meta.name} — ${isPreview ? t('composio.previewTooltip') : meta.description}`}
aria-label={`${meta.name}, ${statusLabel}. ${ctaLabel}.`}
- className={`group flex h-full w-full flex-col justify-center items-center rounded-2xl border p-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${
+ className={`group relative flex h-full w-full flex-col justify-center items-center rounded-2xl border p-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${
isConnected
? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] hover:bg-sage-50 dark:border-sage-500/30 dark:bg-sage-500/10 dark:hover:bg-sage-500/15'
- : isPending
- ? 'border-amber-200 bg-amber-50/40 hover:bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15'
- : isExpired || isError
- ? 'border-coral-200 bg-coral-50/30 hover:bg-coral-50/50 dark:border-coral-500/30 dark:bg-coral-500/10 dark:hover:bg-coral-500/15'
- : 'border-stone-200 bg-white hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60'
+ : isPreview
+ ? 'border-amber-200 bg-amber-50/60 shadow-[0_0_0_1px_rgba(245,158,11,0.12)] hover:bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15'
+ : isPending
+ ? 'border-amber-200 bg-amber-50/40 hover:bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15'
+ : isExpired || isError
+ ? 'border-coral-200 bg-coral-50/30 hover:bg-coral-50/50 dark:border-coral-500/30 dark:bg-coral-500/10 dark:hover:bg-coral-500/15'
+ : 'border-stone-200 bg-white hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60'
}`}>
+ {isPreview && (
+
+ {t('composio.previewBadge')}
+
+ )}
{meta.icon}
@@ -202,7 +218,9 @@ function ComposioConnectorTile({
className={`line-clamp-1 text-[10px] font-medium ${
hasComposioError
? 'text-amber-700 dark:text-amber-300'
- : composioStatusColor(connection)
+ : isPreview
+ ? 'text-amber-700 dark:text-amber-300'
+ : composioStatusColor(connection)
}`}>
{statusLabel}
@@ -400,6 +418,12 @@ export default function Skills() {
error: composioError,
refresh: refreshComposio,
} = useComposioIntegrations();
+ const {
+ agentReady: agentReadyComposioToolkits,
+ loading: agentReadyComposioLoading,
+ error: agentReadyComposioError,
+ } = useAgentReadyComposioToolkits();
+ const agentReadinessKnown = !agentReadyComposioLoading && agentReadyComposioError === null;
const [channelModalDef, setChannelModalDef] = useState(null);
const [composioModalToolkit, setComposioModalToolkit] = useState(
@@ -1074,6 +1098,11 @@ export default function Skills() {
meta={meta}
connection={connection}
hasComposioError={Boolean(composioError)}
+ agentUnsupported={
+ agentReadinessKnown &&
+ deriveComposioState(connection) === 'connected' &&
+ !agentReadyComposioToolkits.has(meta.slug)
+ }
testId={`skill-install-composio-${meta.slug}`}
onOpen={() => setComposioModalToolkit(meta)}
onRetryGlobal={() => void refreshComposio()}
@@ -1133,6 +1162,9 @@ export default function Skills() {
{
void refreshComposio();
void dismissPendingEscalationIfResolved(`composio:${composioModalToolkit.slug}`);
diff --git a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx
index 59c75bb5d..ee0621df2 100644
--- a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx
+++ b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx
@@ -176,6 +176,33 @@ describe('Skills page — Composio catalog fallback', () => {
expect(previewBadges).toHaveLength(0);
});
+ it('marks connected Zoho Mail as preview when the agent cannot use it yet', () => {
+ composioToolkits = ['zoho_mail'];
+ composioConnectionByToolkit = new Map([
+ ['zoho_mail', { id: 'ca_zoho', toolkit: 'zoho_mail', status: 'ACTIVE' }],
+ ]);
+ agentReadyState = { agentReady: new Set(['gmail']), loading: false, error: null };
+
+ renderWithProviders(, { initialEntries: ['/skills'] });
+
+ const integrationsSection = screen
+ .getByRole('heading', { name: 'Composio Integrations' })
+ .closest('.rounded-2xl');
+ expect(integrationsSection).not.toBeNull();
+ const zohoTile = within(integrationsSection as HTMLElement).getByRole('button', {
+ name: /Zoho Mail.*Preview/i,
+ });
+
+ expect(within(zohoTile).getByTestId('composio-preview-badge-zoho_mail')).toHaveTextContent(
+ 'Preview'
+ );
+ expect(within(zohoTile).getAllByText('Preview')).toHaveLength(2);
+
+ fireEvent.click(zohoTile);
+
+ expect(screen.getByText(/Agent integration coming soon/i)).toBeInTheDocument();
+ });
+
it('shows a local-mode composio API key banner when no key is configured', async () => {
sessionToken = 'header.payload.local';
composioModeStatus = { result: { mode: 'direct', api_key_set: false }, logs: [] };
diff --git a/src/openhuman/composio/tools_tests.rs b/src/openhuman/composio/tools_tests.rs
index 528ffea87..4dabb40e5 100644
--- a/src/openhuman/composio/tools_tests.rs
+++ b/src/openhuman/composio/tools_tests.rs
@@ -629,6 +629,58 @@ fn retain_connected_tools_drops_unconnected_toolkits_case_insensitively() {
assert!(!names.contains(&"NOTION_CREATE_PAGE"));
}
+#[test]
+fn retain_connected_tools_keeps_multi_segment_connected_toolkits() {
+ use crate::openhuman::composio::types::{
+ ComposioToolFunction, ComposioToolSchema, ComposioToolsResponse,
+ };
+ use std::collections::HashSet;
+
+ let mut resp = ComposioToolsResponse {
+ tools: vec![
+ ComposioToolSchema {
+ kind: "function".into(),
+ function: ComposioToolFunction {
+ name: "ZOHO_MAIL_SEND_EMAIL".into(),
+ description: None,
+ parameters: None,
+ },
+ },
+ ComposioToolSchema {
+ kind: "function".into(),
+ function: ComposioToolFunction {
+ name: "ONE_DRIVE_GET_FILE".into(),
+ description: None,
+ parameters: None,
+ },
+ },
+ ComposioToolSchema {
+ kind: "function".into(),
+ function: ComposioToolFunction {
+ name: "GMAIL_SEND_EMAIL".into(),
+ description: None,
+ parameters: None,
+ },
+ },
+ ],
+ };
+
+ let connected: HashSet = ["zoho_mail".to_string(), "one_drive".to_string()]
+ .into_iter()
+ .collect();
+ let dropped = retain_connected_tools(&mut resp, &connected);
+
+ assert_eq!(dropped, 1, "should only drop the disconnected gmail tool");
+ let names: Vec<&str> = resp
+ .tools
+ .iter()
+ .map(|t| t.function.name.as_str())
+ .collect();
+ assert!(names.contains(&"ZOHO_MAIL_SEND_EMAIL"));
+ assert!(names.contains(&"ONE_DRIVE_GET_FILE"));
+ assert!(!names.contains(&"GMAIL_SEND_EMAIL"));
+}
+
#[test]
fn normalized_scope_toolkits_prefers_requested_filter() {
use std::collections::HashSet;
diff --git a/src/openhuman/memory_sync/composio/providers/tool_scope.rs b/src/openhuman/memory_sync/composio/providers/tool_scope.rs
index 558641fdc..9923e370d 100644
--- a/src/openhuman/memory_sync/composio/providers/tool_scope.rs
+++ b/src/openhuman/memory_sync/composio/providers/tool_scope.rs
@@ -85,27 +85,26 @@ pub fn find_curated<'a>(catalog: &'a [CuratedTool], slug: &str) -> Option<&'a Cu
/// Extract the toolkit slug from a Composio action slug.
///
-/// All Composio action slugs follow the convention `__…`
-/// (e.g. `GMAIL_SEND_EMAIL` → `gmail`). Returns the lowercased prefix
-/// before the first underscore, or `None` if the slug has no underscore.
-///
-/// **Assumption:** toolkit identifiers themselves do not contain
-/// underscores. Composio honours this for every action we curate today
-/// (`gmail`, `notion`, `googlecalendar`, …). The one historical
-/// exception — `MICROSOFT_TEAMS_*` — extracts to `"microsoft"`, and
-/// [`super::catalog_for_toolkit`] handles the alias by mapping both
-/// `"microsoft"` and `"microsoft_teams"` to the same catalog.
-///
-/// If a future toolkit ships with a multi-word slug containing an
-/// underscore in the *toolkit* portion (e.g. a hypothetical
-/// `FOO_BAR_LIST_ITEMS` whose toolkit is `foo_bar`), this naive split
-/// must be revised — either by consulting a known-toolkits map or by
-/// taking the longest-matching prefix from the registered catalogs.
+/// Most Composio action slugs follow `__…`
+/// (e.g. `GMAIL_SEND_EMAIL` → `gmail`). A few toolkit identifiers contain
+/// underscores themselves; those need known-prefix handling so connected
+/// toolkit checks do not drop actions such as `ZOHO_MAIL_*`.
pub fn toolkit_from_slug(slug: &str) -> Option {
let trimmed = slug.trim();
if trimmed.is_empty() {
return None;
}
+ const MULTI_SEGMENT_TOOLKIT_PREFIXES: &[(&str, &str)] = &[
+ ("MICROSOFT_TEAMS_", "microsoft_teams"),
+ ("ONE_DRIVE_", "one_drive"),
+ ("ZOHO_MAIL_", "zoho_mail"),
+ ];
+ let upper = trimmed.to_ascii_uppercase();
+ for (prefix, toolkit) in MULTI_SEGMENT_TOOLKIT_PREFIXES {
+ if upper.starts_with(prefix) {
+ return Some((*toolkit).to_string());
+ }
+ }
let prefix = trimmed.split('_').next()?;
if prefix.is_empty() {
None
@@ -163,6 +162,22 @@ mod tests {
);
}
+ #[test]
+ fn toolkit_from_slug_handles_known_multi_segment_toolkits() {
+ assert_eq!(
+ toolkit_from_slug("ZOHO_MAIL_SEND_EMAIL"),
+ Some("zoho_mail".to_string())
+ );
+ assert_eq!(
+ toolkit_from_slug("ONE_DRIVE_GET_FILE"),
+ Some("one_drive".to_string())
+ );
+ assert_eq!(
+ toolkit_from_slug("MICROSOFT_TEAMS_SEND_MESSAGE"),
+ Some("microsoft_teams".to_string())
+ );
+ }
+
#[test]
fn find_curated_is_case_insensitive() {
let catalog = &[CuratedTool {