Refactor Composio integration handling and improve UI components

- Updated composio-handler.ts to invalidate the copilot instructions cache upon connection and disconnection.
- Removed unused functions related to tool management in composio-handler.ts.
- Enhanced IPC handlers in ipc.ts to streamline Composio connection processes.
- Introduced ComposioConnectCard in the renderer to display connection status and handle events.
- Refactored tool rendering in App.tsx and chat-sidebar.tsx to utilize new tabbed content for parameters and results.
- Improved Composio tools prompt generation in instructions.ts to clarify integration usage and discovery flow.
- Cleaned up unused code and improved overall structure for better maintainability.
This commit is contained in:
tusharmagar 2026-04-02 15:35:31 +05:30
parent abf6901cc9
commit 7f8d2e64af
18 changed files with 864 additions and 812 deletions

View file

@ -2,11 +2,9 @@ import { shell, BrowserWindow } from 'electron';
import { createAuthServer } from './auth-server.js';
import * as composioClient from '@x/core/dist/composio/client.js';
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
import { composioEnabledToolsRepo } from '@x/core/dist/composio/enabled-tools-repo.js';
import type { EnabledTool } from '@x/core/dist/composio/enabled-tools-repo.js';
import type { LocalConnectedAccount, ZExecuteActionResponse } from '@x/core/dist/composio/types.js';
import { refreshComposioTools } from '@x/core/dist/application/lib/builtin-tools.js';
import { z } from 'zod';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { CURATED_TOOLKIT_SLUGS } from '@x/core/dist/composio/curated-toolkits.js';
import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js';
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
@ -163,6 +161,8 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
if (accountStatus.status === 'ACTIVE') {
// Invalidate instructions cache so the copilot knows about the new connection
invalidateCopilotInstructionsCache();
emitComposioEvent({ toolkitSlug, success: true });
if (toolkitSlug === 'gmail') {
triggerGmailSync();
@ -273,23 +273,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (account) {
// Delete from Composio
await composioClient.deleteConnectedAccount(account.id);
// Delete local record
composioAccountsRepo.deleteAccount(toolkitSlug);
}
// Clean up enabled tools for this toolkit
composioEnabledToolsRepo.disableAllForToolkit(toolkitSlug);
refreshComposioTools();
return { success: true };
} catch (error) {
console.error('[Composio] Disconnect failed:', error);
// Still delete local record even if API call fails
} finally {
// Always clean up local state, even if the API call fails
composioAccountsRepo.deleteAccount(toolkitSlug);
composioEnabledToolsRepo.disableAllForToolkit(toolkitSlug);
refreshComposioTools();
return { success: true };
invalidateCopilotInstructionsCache();
}
return { success: true };
}
/**
@ -314,42 +307,7 @@ export async function useComposioForGoogleCalendar(): Promise<{ enabled: boolean
}
/**
* Execute a Composio action
*/
export async function executeAction(
actionSlug: string,
toolkitSlug: string,
input: Record<string, unknown>
): Promise<z.infer<typeof ZExecuteActionResponse>> {
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
data: null,
successful: false,
error: `Toolkit ${toolkitSlug} is not connected`,
};
}
const result = await composioClient.executeAction(actionSlug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: input,
});
return result;
} catch (error) {
console.error('[Composio] Action execution failed:', error);
return {
successful: false,
data: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* List available Composio toolkits
* List available Composio toolkits filtered to curated list only
*/
export async function listToolkits(cursor?: string): Promise<{
items: Array<{
@ -363,79 +321,12 @@ export async function listToolkits(cursor?: string): Promise<{
nextCursor: string | null;
totalItems: number;
}> {
// Fetch all toolkits and filter to curated list
const result = await composioClient.listToolkits(cursor || null);
const filtered = result.items.filter(item => CURATED_TOOLKIT_SLUGS.has(item.slug));
return {
items: result.items,
items: filtered,
nextCursor: result.next_cursor,
totalItems: result.total_items,
totalItems: filtered.length,
};
}
/**
* List tools for a toolkit with full details
*/
export async function listToolkitToolsDetailed(toolkitSlug: string, search?: string): Promise<{
items: Array<{
slug: string;
name: string;
description: string;
toolkitSlug: string;
inputParameters?: { type?: string; properties?: Record<string, unknown>; required?: string[] };
}>;
}> {
return composioClient.listToolkitToolsDetailed(toolkitSlug, search || null);
}
/**
* Get all enabled tools
*/
export function getEnabledTools(): {
tools: Record<string, { slug: string; name: string; description: string; toolkitSlug: string }>;
} {
const all = composioEnabledToolsRepo.getAll();
const tools: Record<string, { slug: string; name: string; description: string; toolkitSlug: string }> = {};
for (const [slug, tool] of Object.entries(all)) {
tools[slug] = {
slug: tool.slug,
name: tool.name,
description: tool.description,
toolkitSlug: tool.toolkitSlug,
};
}
return { tools };
}
/**
* Enable specific tools from a toolkit
*/
export function enableTools(tools: Array<{
slug: string;
name: string;
description: string;
toolkitSlug: string;
inputParameters?: { type?: string; properties?: Record<string, unknown>; required?: string[] };
}>): { success: boolean } {
const enabledTools: EnabledTool[] = tools.map(t => ({
slug: t.slug,
name: t.name,
description: t.description,
toolkitSlug: t.toolkitSlug,
inputParameters: {
type: 'object' as const,
properties: t.inputParameters?.properties ?? {},
required: t.inputParameters?.required,
},
}));
composioEnabledToolsRepo.enableBatch(enabledTools);
refreshComposioTools();
return { success: true };
}
/**
* Disable specific tools
*/
export function disableTools(toolSlugs: string[]): { success: boolean } {
composioEnabledToolsRepo.disableBatch(toolSlugs);
refreshComposioTools();
return { success: true };
}

View file

@ -34,6 +34,7 @@ import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granol
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
import { setConnectionInitiator } from '@x/core/dist/composio/connection-bridge.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
@ -376,6 +377,9 @@ export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
// Wire the connection bridge so builtin tools (in core) can trigger OAuth (in main)
setConnectionInitiator(composioHandler.initiateConnection);
registerIpcHandlers({
'app:getVersions': async () => {
// args is null for this channel (no request payload)
@ -559,25 +563,10 @@ export function setupIpcHandlers() {
'composio:list-connected': async () => {
return composioHandler.listConnected();
},
'composio:execute-action': async (_event, args) => {
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
},
// Composio Tools Library handlers
'composio:list-toolkits': async (_event, args) => {
return composioHandler.listToolkits(args.cursor);
},
'composio:list-toolkit-tools': async (_event, args) => {
return composioHandler.listToolkitToolsDetailed(args.toolkitSlug, args.search);
},
'composio:get-enabled-tools': async () => {
return composioHandler.getEnabledTools();
},
'composio:enable-tools': async (_event, args) => {
return composioHandler.enableTools(args.tools);
},
'composio:disable-tools': async (_event, args) => {
return composioHandler.disableTools(args.toolSlugs);
},
'composio:use-composio-for-google': async () => {
return composioHandler.useComposioForGoogle();
},

View file

@ -34,9 +34,10 @@ import {
import { Shimmer } from '@/components/ai-elements/shimmer';
import { useSmoothedText } from './hooks/useSmoothedText';
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool';
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
import { AppActionCard } from '@/components/ai-elements/app-action-card';
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
import { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions';
@ -67,6 +68,8 @@ import {
createEmptyChatTabViewState,
getWebSearchCardData,
getAppActionCardData,
getComposioConnectCardData,
getComposioActionCardData,
inferRunTitleFromMessage,
isChatMessage,
isErrorMessage,
@ -76,6 +79,7 @@ import {
parseAttachedFiles,
toToolState,
} from '@/lib/chat-conversation'
import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js'
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner"
@ -2242,6 +2246,12 @@ function App() {
}
handlePromptSubmitRef.current = handlePromptSubmit
const handleComposioConnected = useCallback((toolkitSlug: string) => {
// Auto-send a continuation message when a Composio toolkit connects
const name = composioDisplayNames[toolkitSlug] || toolkitSlug
handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] })
}, [])
const handleStop = useCallback(async () => {
if (!runId) return
const now = Date.now()
@ -3826,6 +3836,21 @@ function App() {
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={handleComposioConnected}
/>
)
}
const composioActionData = getComposioActionCardData(item)
const toolTitle = composioActionData ? composioActionData.label : item.name
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
@ -3836,15 +3861,12 @@ function App() {
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
>
<ToolHeader
title={item.name}
title={toolTitle}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
<ToolContent>
<ToolInput input={input} />
{output !== null ? (
<ToolOutput output={output} errorText={errorText} />
) : null}
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)
@ -4470,6 +4492,7 @@ function App() {
ttsMode={ttsMode}
onToggleTts={handleToggleTts}
onTtsModeChange={handleTtsModeChange}
onComposioConnected={handleComposioConnected}
/>
)}
{/* Rendered last so its no-drag region paints over the sidebar drag region */}

View file

@ -0,0 +1,150 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
CheckCircleIcon,
Link2Icon,
LoaderIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
interface ComposioConnectCardProps {
toolkitSlug: string;
toolkitDisplayName: string;
status: "pending" | "running" | "completed" | "error";
alreadyConnected?: boolean;
onConnected?: (toolkitSlug: string) => void;
}
export function ComposioConnectCard({
toolkitSlug,
toolkitDisplayName,
status,
alreadyConnected,
onConnected,
}: ComposioConnectCardProps) {
const [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "connected" | "error"
>(alreadyConnected ? "connected" : "idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [didFireCallback, setDidFireCallback] = useState(false);
// If the tool result already says connected, reflect that
useEffect(() => {
if (alreadyConnected) {
setConnectionState("connected");
}
}, [alreadyConnected]);
// Listen for composio:didConnect events
useEffect(() => {
const cleanup = window.ipc.on(
"composio:didConnect",
(event: { toolkitSlug: string; success: boolean; error?: string }) => {
if (event.toolkitSlug !== toolkitSlug) return;
if (event.success) {
setConnectionState("connected");
setErrorMessage(null);
} else {
setConnectionState("error");
setErrorMessage(event.error || "Connection failed");
}
}
);
return cleanup;
}, [toolkitSlug]);
// Fire onConnected callback once when connected
useEffect(() => {
if (connectionState === "connected" && !didFireCallback && !alreadyConnected) {
setDidFireCallback(true);
onConnected?.(toolkitSlug);
}
}, [connectionState, didFireCallback, alreadyConnected, onConnected, toolkitSlug]);
const handleConnect = useCallback(async () => {
setConnectionState("connecting");
setErrorMessage(null);
try {
const result = await window.ipc.invoke("composio:initiate-connection", {
toolkitSlug,
});
if (!result.success) {
setConnectionState("error");
setErrorMessage(result.error || "Failed to initiate connection");
}
// Success will be handled by composio:didConnect event
} catch {
setConnectionState("error");
setErrorMessage("Failed to initiate connection");
}
}, [toolkitSlug]);
const isToolRunning = status === "pending" || status === "running";
return (
<div className="not-prose mb-4 flex items-center gap-3 rounded-lg border px-3 py-2.5">
{/* Icon */}
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
<WrenchIcon className="size-3.5 text-muted-foreground" />
</div>
{/* Name & status text */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{toolkitDisplayName || toolkitSlug}
</span>
{connectionState === "connected" && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
{connectionState === "error" && errorMessage && (
<p className="text-xs text-destructive truncate">{errorMessage}</p>
)}
{connectionState === "idle" && isToolRunning && (
<p className="text-xs text-muted-foreground">
Waiting to connect...
</p>
)}
</div>
{/* Action area */}
{connectionState === "connected" ? (
<CheckCircleIcon className="size-4 text-green-600 flex-shrink-0" />
) : connectionState === "connecting" ? (
<Button size="sm" disabled className="text-xs h-7 flex-shrink-0">
<LoaderIcon className="size-3 animate-spin mr-1" />
Connecting...
</Button>
) : connectionState === "error" ? (
<div className="flex items-center gap-1.5 flex-shrink-0">
<XCircleIcon className="size-3.5 text-destructive" />
<Button
size="sm"
variant="outline"
onClick={handleConnect}
className="text-xs h-7"
>
Retry
</Button>
</div>
) : isToolRunning ? (
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground flex-shrink-0" />
) : (
<Button
size="sm"
onClick={handleConnect}
className="text-xs h-7 flex-shrink-0"
>
<Link2Icon className="size-3 mr-1" />
Connect
</Button>
)}
</div>
);
}

View file

@ -36,7 +36,7 @@ export const PermissionRequest = ({
...props
}: PermissionRequestProps) => {
// Extract command from arguments if it's executeCommand
const command = toolCall.toolName === "executeCommand"
const command = toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments))
@ -80,12 +80,12 @@ export const PermissionRequest = ({
</p>
</div>
{isResponded && (
<Badge
variant="secondary"
<Badge
variant="secondary"
className={cn(
"shrink-0",
isApproved
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
isApproved
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
)}
>

View file

@ -16,8 +16,8 @@ import {
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
const formatToolValue = (value: unknown) => {
if (typeof value === "string") return value;
try {
@ -37,7 +37,7 @@ const ToolCode = ({
}) => (
<pre
className={cn(
"whitespace-pre-wrap text-xs font-mono",
"whitespace-pre-wrap text-xs font-mono break-all",
className
)}
>
@ -129,64 +129,90 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
/* ── Tabbed content (Parameters / Result) ────────────────────────── */
export type ToolTabbedContentProps = {
input: ToolUIPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md border bg-muted/50 p-4 text-foreground">
<ToolCode code={formatToolValue(input ?? {})} />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"];
errorText: ToolUIPart["errorText"];
errorText?: ToolUIPart["errorText"];
};
export const ToolOutput = ({
className,
export const ToolTabbedContent = ({
input,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
}: ToolTabbedContentProps) => {
const [activeTab, setActiveTab] = useState<"parameters" | "result">("parameters");
const hasOutput = output != null || !!errorText;
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = <ToolCode code={formatToolValue(output ?? null)} />;
} else if (typeof output === "string") {
Output = <ToolCode code={formatToolValue(output)} />;
let OutputNode: ReactNode = null;
if (errorText) {
OutputNode = <ToolCode code={errorText} className="text-destructive" />;
} else if (output != null) {
if (typeof output === "object" && !isValidElement(output)) {
OutputNode = <ToolCode code={formatToolValue(output)} />;
} else if (typeof output === "string") {
OutputNode = <ToolCode code={output} />;
} else {
OutputNode = <div>{output as ReactNode}</div>;
}
}
return (
<div className={cn("space-y-2 p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md border p-4 text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && (
<div className="mb-2 font-sans text-xs text-destructive">
{errorText}
<div className="border-t">
{/* Tabs */}
<div className="flex">
<button
type="button"
className={cn(
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
activeTab === "parameters"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("parameters")}
>
Parameters
</button>
<button
type="button"
className={cn(
"px-4 py-2 text-xs font-medium transition-colors border-b-2",
activeTab === "result"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("result")}
>
Result
</button>
</div>
{/* Tab content */}
<div className="p-3">
{activeTab === "parameters" && (
<div className="rounded-md border bg-muted/50 p-3 max-h-64 overflow-auto">
<ToolCode code={formatToolValue(input ?? {})} />
</div>
)}
{activeTab === "result" && (
<div
className={cn(
"rounded-md border p-3 max-h-64 overflow-auto",
errorText ? "bg-destructive/10" : "bg-muted/50"
)}
>
{hasOutput ? (
<div className={cn(errorText && "text-destructive")}>
{OutputNode}
</div>
) : (
<span className="text-xs text-muted-foreground">(pending...)</span>
)}
</div>
)}
{Output}
</div>
</div>
);
};

View file

@ -16,8 +16,9 @@ import {
MessageResponse,
} from '@/components/ai-elements/message'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
import { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions'
@ -34,6 +35,8 @@ import {
type PermissionResponse,
createEmptyChatTabViewState,
getWebSearchCardData,
getComposioConnectCardData,
getComposioActionCardData,
isChatMessage,
isErrorMessage,
isToolCall,
@ -121,6 +124,7 @@ interface ChatSidebarProps {
ttsMode?: 'summary' | 'full'
onToggleTts?: () => void
onTtsModeChange?: (mode: 'summary' | 'full') => void
onComposioConnected?: (toolkitSlug: string) => void
}
export function ChatSidebar({
@ -171,6 +175,7 @@ export function ChatSidebar({
ttsMode,
onToggleTts,
onTtsModeChange,
onComposioConnected,
}: ChatSidebarProps) {
const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false)
@ -337,6 +342,21 @@ export function ChatSidebar({
/>
)
}
const composioConnectData = getComposioConnectCardData(item)
if (composioConnectData) {
return (
<ComposioConnectCard
key={item.id}
toolkitSlug={composioConnectData.toolkitSlug}
toolkitDisplayName={composioConnectData.toolkitDisplayName}
status={item.status}
alreadyConnected={composioConnectData.alreadyConnected}
onConnected={onComposioConnected}
/>
)
}
const composioActionData = getComposioActionCardData(item)
const toolTitle = composioActionData ? composioActionData.label : item.name
const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
@ -346,10 +366,9 @@ export function ChatSidebar({
open={isToolOpenForTab?.(tabId, item.id) ?? false}
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
>
<ToolHeader title={item.name} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
<ToolContent>
<ToolInput input={input} />
{output !== null ? <ToolOutput output={output} errorText={errorText} /> : null}
<ToolTabbedContent input={input} output={output} errorText={errorText} />
</ToolContent>
</Tool>
)

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronDown, ChevronRight, Check, Link2, Unlink, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
import {
Dialog,
@ -724,14 +724,6 @@ interface ToolkitInfo {
composio_managed_auth_schemes: string[]
}
interface ToolInfo {
slug: string
name: string
description: string
toolkitSlug: string
inputParameters?: { type?: string; properties?: Record<string, unknown>; required?: string[] }
}
function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
// API key state
const [apiKeyConfigured, setApiKeyConfigured] = useState(false)
@ -748,18 +740,6 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
const [connectedToolkits, setConnectedToolkits] = useState<Set<string>>(new Set())
const [connectingToolkit, setConnectingToolkit] = useState<string | null>(null)
// Tool selection state
const [expandedToolkit, setExpandedToolkit] = useState<string | null>(null)
const [toolkitTools, setToolkitTools] = useState<Record<string, ToolInfo[]>>({})
const [toolsLoading, setToolsLoading] = useState<string | null>(null)
const [enabledToolSlugs, setEnabledToolSlugs] = useState<Set<string>>(new Set())
// Tool search state (per-toolkit, server-side via Composio API)
const [toolSearchQuery, setToolSearchQuery] = useState("")
const [toolSearchResults, setToolSearchResults] = useState<ToolInfo[] | null>(null)
const [toolSearchLoading, setToolSearchLoading] = useState(false)
const toolSearchTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
// Check API key configuration
const checkApiKey = useCallback(async () => {
try {
@ -783,16 +763,6 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
}
}, [])
// Load enabled tools
const loadEnabledTools = useCallback(async () => {
try {
const result = await window.ipc.invoke("composio:get-enabled-tools", null)
setEnabledToolSlugs(new Set(Object.keys(result.tools)))
} catch {
// ignore
}
}, [])
// Load toolkits
const loadToolkits = useCallback(async () => {
setToolkitsLoading(true)
@ -811,8 +781,7 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
if (!dialogOpen) return
checkApiKey()
loadConnected()
loadEnabledTools()
}, [dialogOpen, checkApiKey, loadConnected, loadEnabledTools])
}, [dialogOpen, checkApiKey, loadConnected])
// Load toolkits when API key is configured
useEffect(() => {
@ -883,151 +852,12 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
next.delete(toolkitSlug)
return next
})
// Remove enabled tools for this toolkit from local state
setEnabledToolSlugs(prev => {
const toolsForToolkit = toolkitTools[toolkitSlug] || []
const next = new Set(prev)
for (const t of toolsForToolkit) {
next.delete(t.slug)
}
return next
})
if (expandedToolkit === toolkitSlug) {
setExpandedToolkit(null)
}
toast.success(`Disconnected from ${toolkitSlug}`)
} catch {
toast.error("Failed to disconnect")
}
}
// Load tools for a toolkit
const loadToolsForToolkit = async (toolkitSlug: string) => {
if (toolkitTools[toolkitSlug]) return // Already loaded
setToolsLoading(toolkitSlug)
try {
const result = await window.ipc.invoke("composio:list-toolkit-tools", { toolkitSlug })
setToolkitTools(prev => ({ ...prev, [toolkitSlug]: result.items }))
} catch {
toast.error("Failed to load tools")
} finally {
setToolsLoading(null)
}
}
// Search tools within a toolkit (debounced, server-side)
const handleToolSearch = useCallback((toolkitSlug: string, query: string) => {
setToolSearchQuery(query)
// Clear pending timer
if (toolSearchTimerRef.current) {
clearTimeout(toolSearchTimerRef.current)
toolSearchTimerRef.current = null
}
// Empty query: clear search results, show all tools
if (!query.trim()) {
setToolSearchResults(null)
setToolSearchLoading(false)
return
}
setToolSearchLoading(true)
// Debounce 350ms before hitting the API
toolSearchTimerRef.current = setTimeout(async () => {
try {
const result = await window.ipc.invoke("composio:list-toolkit-tools", {
toolkitSlug,
search: query.trim(),
})
setToolSearchResults(result.items)
} catch {
toast.error("Search failed")
setToolSearchResults(null)
} finally {
setToolSearchLoading(false)
}
}, 350)
}, [])
// Clear tool search when switching toolkits
useEffect(() => {
setToolSearchQuery("")
setToolSearchResults(null)
setToolSearchLoading(false)
if (toolSearchTimerRef.current) {
clearTimeout(toolSearchTimerRef.current)
toolSearchTimerRef.current = null
}
}, [expandedToolkit])
// Clean up pending search timer on unmount
useEffect(() => {
return () => {
if (toolSearchTimerRef.current) {
clearTimeout(toolSearchTimerRef.current)
}
}
}, [])
// Toggle toolkit expansion
const handleToggleToolkit = (toolkitSlug: string) => {
if (expandedToolkit === toolkitSlug) {
setExpandedToolkit(null)
} else {
setExpandedToolkit(toolkitSlug)
if (connectedToolkits.has(toolkitSlug)) {
loadToolsForToolkit(toolkitSlug)
}
}
}
// Enable/disable a tool
const handleToggleTool = async (tool: ToolInfo, enable: boolean) => {
try {
if (enable) {
await window.ipc.invoke("composio:enable-tools", { tools: [tool] })
setEnabledToolSlugs(prev => new Set([...prev, tool.slug]))
} else {
await window.ipc.invoke("composio:disable-tools", { toolSlugs: [tool.slug] })
setEnabledToolSlugs(prev => {
const next = new Set(prev)
next.delete(tool.slug)
return next
})
}
} catch {
toast.error("Failed to update tool")
}
}
// Enable/disable all tools for a toolkit
const handleToggleAllTools = async (toolkitSlug: string, enable: boolean) => {
const tools = toolkitTools[toolkitSlug] || []
if (tools.length === 0) return
try {
if (enable) {
await window.ipc.invoke("composio:enable-tools", { tools })
setEnabledToolSlugs(prev => {
const next = new Set(prev)
for (const t of tools) next.add(t.slug)
return next
})
} else {
await window.ipc.invoke("composio:disable-tools", { toolSlugs: tools.map(t => t.slug) })
setEnabledToolSlugs(prev => {
const next = new Set(prev)
for (const t of tools) next.delete(t.slug)
return next
})
}
} catch {
toast.error("Failed to update tools")
}
}
// Filter toolkits by search
const filteredToolkits = searchQuery.trim()
? toolkits.filter(t =>
@ -1125,29 +955,10 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
{filteredToolkits.map((toolkit) => {
const isConnected = connectedToolkits.has(toolkit.slug)
const isConnecting = connectingToolkit === toolkit.slug
const isExpanded = expandedToolkit === toolkit.slug
const tools = toolkitTools[toolkit.slug] || []
const isLoadingTools = toolsLoading === toolkit.slug
const enabledCount = tools.filter(t => enabledToolSlugs.has(t.slug)).length
const allEnabled = tools.length > 0 && enabledCount === tools.length
// Use search results when actively searching, otherwise show all tools
const displayTools = (isExpanded && toolSearchResults !== null) ? toolSearchResults : tools
const isSearching = isExpanded && toolSearchQuery.trim().length > 0
return (
<div key={toolkit.slug} className={cn(
"border rounded-lg overflow-hidden transition-colors",
isExpanded && "border-border/80 shadow-sm"
)}>
{/* Toolkit card header */}
<button
onClick={() => handleToggleToolkit(toolkit.slug)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-accent/50",
isExpanded && "bg-accent/30"
)}
>
<div key={toolkit.slug} className="border rounded-lg overflow-hidden">
<div className="flex items-center gap-3 px-3 py-2.5">
{/* Logo */}
{toolkit.meta.logo ? (
<img
@ -1166,188 +977,42 @@ function ToolsLibrarySettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">{toolkit.name}</span>
<span className="text-[10px] text-muted-foreground tabular-nums">
{toolkit.meta.tools_count} tools
</span>
{isConnected && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
{enabledCount > 0 && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary tabular-nums">
{enabledCount} enabled
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{toolkit.meta.description}
</p>
</div>
{/* Expand icon */}
{isExpanded ? (
<ChevronDown className="size-4 text-muted-foreground flex-shrink-0" />
{/* Connect / Disconnect button */}
{isConnected ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDisconnect(toolkit.slug)}
className="text-xs h-7 flex-shrink-0"
>
Disconnect
</Button>
) : (
<ChevronRight className="size-4 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div className="border-t px-3 py-2.5 space-y-2.5 bg-muted/20">
{/* Connection controls */}
<div className="flex items-center gap-2">
{isConnected ? (
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); handleDisconnect(toolkit.slug) }}
className="text-xs h-7"
>
<Unlink className="size-3 mr-1" />
Disconnect
</Button>
<Button
size="sm"
onClick={() => handleConnect(toolkit.slug)}
disabled={isConnecting}
className="text-xs h-7 flex-shrink-0"
>
{isConnecting ? (
<><Loader2 className="size-3 animate-spin mr-1" />Connecting...</>
) : (
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleConnect(toolkit.slug) }}
disabled={isConnecting}
className="text-xs h-7"
>
{isConnecting ? (
<><Loader2 className="size-3 animate-spin mr-1" />Connecting...</>
) : (
<><Link2 className="size-3 mr-1" />Connect</>
)}
</Button>
<><Link2 className="size-3 mr-1" />Connect</>
)}
{/* Enable/Disable all (only if connected and tools loaded) */}
{isConnected && tools.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleToggleAllTools(toolkit.slug, !allEnabled)
}}
className="text-xs h-7 ml-auto"
>
{allEnabled ? "Disable All" : "Enable All"}
</Button>
)}
</div>
{/* Tools section (only if connected) */}
{isConnected && (
<div className="space-y-2">
{/* Tool search input — shown when toolkit has many tools */}
{!isLoadingTools && tools.length > 5 && (
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<input
type="text"
value={toolSearchQuery}
onChange={(e) => handleToolSearch(toolkit.slug, e.target.value)}
placeholder={`Search ${toolkit.meta.tools_count} tools...`}
className="w-full h-7 pl-7 pr-7 text-xs rounded-md border border-border bg-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
{toolSearchLoading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 size-3 animate-spin text-muted-foreground" />
)}
{!toolSearchLoading && toolSearchQuery && (
<button
onClick={() => handleToolSearch(toolkit.slug, "")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
)}
{/* Search results summary */}
{isSearching && !toolSearchLoading && toolSearchResults !== null && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="tabular-nums">{toolSearchResults.length}</span>
<span>{toolSearchResults.length === 1 ? 'tool' : 'tools'} matching &ldquo;{toolSearchQuery}&rdquo;</span>
</div>
)}
{/* Tool list */}
{isLoadingTools ? (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Loading tools...
</div>
) : displayTools.length === 0 ? (
<div className="py-3 text-center">
<p className="text-xs text-muted-foreground">
{isSearching
? `No tools found for "${toolSearchQuery}"`
: "No tools found"
}
</p>
{isSearching && (
<button
onClick={() => handleToolSearch(toolkit.slug, "")}
className="mt-1 text-[11px] text-primary hover:underline"
>
Clear search
</button>
)}
</div>
) : (
<div className="max-h-[240px] overflow-y-auto space-y-0.5 -mx-1 px-1">
{displayTools.map((tool) => {
const isEnabled = enabledToolSlugs.has(tool.slug)
return (
<label
key={tool.slug}
className={cn(
"flex items-start gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors",
isEnabled ? "bg-primary/5" : "hover:bg-accent/50"
)}
>
<div className="pt-0.5">
<div
onClick={(e) => {
e.preventDefault()
handleToggleTool(tool, !isEnabled)
}}
className={cn(
"size-4 rounded border flex items-center justify-center transition-colors cursor-pointer",
isEnabled
? "bg-primary border-primary"
: "border-border hover:border-primary/50"
)}
>
{isEnabled && <Check className="size-3 text-primary-foreground" />}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">{tool.name}</div>
<div className="text-[11px] text-muted-foreground line-clamp-2">
{tool.description}
</div>
</div>
</label>
)
})}
</div>
)}
</div>
)}
{/* Not connected hint */}
{!isConnected && (
<p className="text-xs text-muted-foreground">
Connect this toolkit to browse and enable its tools.
</p>
)}
</div>
)}
</Button>
)}
</div>
</div>
)
})}

View file

@ -1,6 +1,7 @@
import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
export interface MessageAttachment {
path: string
@ -253,6 +254,94 @@ export const parseAttachedFiles = (content: string): { message: string; files: s
return { message: cleanMessage.trim(), files }
}
// Composio connect card data
export type ComposioConnectCardData = {
toolkitSlug: string
toolkitDisplayName: string
alreadyConnected: boolean
}
// Display names imported from @x/shared/composio (single source of truth)
const composioDisplayNames = COMPOSIO_DISPLAY_NAMES
export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => {
if (tool.name !== 'composio-connect-toolkit') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const toolkitSlug = (input?.toolkitSlug as string) || ''
const alreadyConnected = result?.alreadyConnected === true
return {
toolkitSlug,
toolkitDisplayName: composioDisplayNames[toolkitSlug] || toolkitSlug,
alreadyConnected,
}
}
// Composio action card data (for search, execute, list tools)
export type ComposioActionCardData = {
actionType: 'search' | 'execute' | 'list'
label: string
}
export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardData | null => {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
if (tool.name === 'composio-search-tools') {
const query = (input?.query as string) || 'tools'
const toolkitSlug = input?.toolkitSlug as string | undefined
const toolkit = toolkitSlug ? composioDisplayNames[toolkitSlug] || toolkitSlug : null
const count = (result?.resultCount as number) ?? null
let label = `Searching for "${query}"`
if (toolkit) label += ` in ${toolkit}`
if (count !== null && tool.status === 'completed') {
label = count > 0 ? `Found ${count} tool${count !== 1 ? 's' : ''} for "${query}"` : `No tools found for "${query}"`
if (toolkit) label += ` in ${toolkit}`
}
return { actionType: 'search', label }
}
if (tool.name === 'composio-execute-tool') {
const toolSlug = (input?.toolSlug as string) || ''
const toolkitSlug = (input?.toolkitSlug as string) || ''
const toolkit = composioDisplayNames[toolkitSlug] || toolkitSlug
const successful = result?.successful as boolean | undefined
// Make the tool slug human-readable: GITHUB_ISSUES_LIST_FOR_REPO → "Issues list for repo"
const readableName = toolSlug
.replace(/^[A-Z]+_/, '') // Remove toolkit prefix
.toLowerCase()
.replace(/_/g, ' ')
.replace(/^\w/, c => c.toUpperCase())
let label = `Running ${readableName}`
if (toolkit) label += ` on ${toolkit}`
if (tool.status === 'completed') {
label = successful === false ? `Failed: ${readableName}` : `${readableName}`
if (toolkit) label += ` on ${toolkit}`
}
return { actionType: 'execute', label }
}
if (tool.name === 'composio-list-toolkits') {
const count = (result?.totalCount as number) ?? null
const connected = (result?.connectedCount as number) ?? null
let label = 'Listing available integrations'
if (count !== null && tool.status === 'completed') {
label = `${count} integrations available`
if (connected !== null && connected > 0) label += `, ${connected} connected`
}
return { actionType: 'list', label }
}
return null
}
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()

View file

@ -370,8 +370,7 @@ function formatLlmStreamError(rawError: unknown): string {
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
if (id === "copilot" || id === "rowboatx") {
// Rebuild tools from current BuiltinTools to pick up dynamically
// registered Composio tools (added via refreshComposioTools).
// Rebuild tools from current BuiltinTools (includes Composio meta-tools).
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
tools[name] = { type: "builtin", name };

View file

@ -1,51 +1,55 @@
import { skillCatalog } from "./skills/index.js"; // eslint-disable-line @typescript-eslint/no-unused-vars -- used in template literal
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
import { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "../../composio/curated-toolkits.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
/**
* Generate dynamic instructions section for Composio tools.
* Returns empty string if no tools are enabled.
* Generate dynamic instructions section for Composio integrations.
* Lists connected toolkits and explains the meta-tool discovery flow.
*/
async function getComposioToolsPrompt(): Promise<string> {
if (!(await isComposioConfigured())) return '';
const enabledTools = composioEnabledToolsRepo.getAll();
const toolEntries = Object.values(enabledTools);
if (toolEntries.length === 0) return '';
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
// Group tools by toolkit
const byToolkit: Record<string, Array<{ slug: string; name: string; description: string }>> = {};
for (const tool of toolEntries) {
if (!byToolkit[tool.toolkitSlug]) {
byToolkit[tool.toolkitSlug] = [];
}
byToolkit[tool.toolkitSlug].push(tool);
let prompt = `\n## Composio Integrations\n\n`;
prompt += `You can connect to external services (Gmail, Slack, GitHub, Notion, etc.) via Composio.\n\n`;
if (connectedToolkits.length > 0) {
const connectedNames = connectedToolkits
.map(slug => CURATED_TOOLKITS.find(t => t.slug === slug)?.displayName ?? slug)
.join(', ');
prompt += `**Currently connected:** ${connectedNames}\n\n`;
} else {
prompt += `**No services connected yet.** Use \`composio-list-toolkits\` to show available integrations, or \`composio-connect-toolkit\` to help the user connect one.\n\n`;
}
// Check which toolkits are connected
const connectedToolkits = new Set(composioAccountsRepo.getConnectedToolkits());
prompt += `**CRITICAL: NEVER say "I can't access [service]" or "I don't have access to [service]" without FIRST trying Composio.** If a user asks about ANY third-party service (LinkedIn, Gmail, GitHub, Slack, etc.), your FIRST action must be to check \`composio-list-toolkits\` or try \`composio-connect-toolkit\`. Never give up before trying.\n\n`;
let prompt = `\n## Composio Integration Tools\n\n`;
prompt += `You have access to external service integrations via Composio. These tools are prefixed with \`composio-\` and connect to third-party services on the user's behalf. The user has enabled these tools in Settings > Tools Library.\n\n`;
prompt += `**How to use Composio tools:**\n`;
prompt += `- Each tool is named \`composio-{ACTION_SLUG}\` (e.g., \`composio-GMAIL_SEND_EMAIL\`)\n`;
prompt += `- Call them like any other builtin tool — pass the required parameters and they execute via the connected account\n`;
prompt += `- If a tool returns an error about the toolkit not being connected, inform the user they need to connect it in Settings > Tools Library\n`;
prompt += `- Always confirm with the user before taking actions that send messages, create items, or modify data in external services\n\n`;
prompt += `**Discovery & Execution Flow:**\n`;
prompt += `1. When the user asks to interact with a service (e.g., "get my LinkedIn profile", "check my email", "list GitHub issues"):\n`;
prompt += ` a. Check if the service is connected (via \`composio-list-toolkits\` or the connected list above)\n`;
prompt += ` b. If NOT connected, call \`composio-connect-toolkit\` immediately — do NOT ask for confirmation or tell the user you can't do it\n`;
prompt += ` c. If connected, proceed to search and execute\n`;
prompt += `2. Use \`composio-search-tools\` with SHORT keyword queries (e.g., "list issues", "send email", "get profile") — avoid long sentences.\n`;
prompt += `3. Read the \`inputSchema\` from search results carefully — note which fields are in \`required\`.\n`;
prompt += `4. Call \`composio-execute-tool\` with the tool slug, toolkit slug, AND all required \`arguments\`. For tools with empty \`properties: {}\`, pass \`arguments: {}\`.\n`;
for (const [toolkitSlug, tools] of Object.entries(byToolkit)) {
const isConnected = connectedToolkits.has(toolkitSlug);
const statusBadge = isConnected ? '(Connected)' : '(Not Connected)';
prompt += `### ${toolkitSlug.charAt(0).toUpperCase() + toolkitSlug.slice(1)} ${statusBadge}\n`;
for (const tool of tools) {
prompt += `- \`composio-${tool.slug}\`${tool.description}\n`;
}
prompt += `\n`;
}
prompt += `**Example — fetching GitHub issues for owner/repo:**\n`;
prompt += `1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\` → finds \`GITHUB_ISSUES_LIST_FOR_REPO\`\n`;
prompt += `2. Schema shows required: \`["owner", "repo"]\` — extract from user's request (e.g., "rowboatlabs/rowboat" → owner: "rowboatlabs", repo: "rowboat")\n`;
prompt += `3. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`\n\n`;
prompt += `**Important:**\n`;
prompt += `- Use short keyword search queries, NOT full sentences (good: "list issues", bad: "get all open issues for a GitHub repository")\n`;
prompt += `- ALWAYS pass required arguments to composio-execute-tool — read the inputSchema from search results\n`;
prompt += `- **If a tool call fails (e.g., missing fields), fix the arguments and retry IMMEDIATELY — do NOT stop and narrate the error to the user. Just fix it and continue.**\n`;
prompt += `- **Multi-part requests:** When the user asks to "connect X and then do Y", complete BOTH parts in one turn. If part 1 (connect) is already done, proceed directly to part 2 (the actual task).\n`;
prompt += `- Confirm with the user before executing tools that send messages, create items, or modify data (NOT for read-only queries or connecting)\n`;
prompt += `- Connecting a toolkit is always safe — just do it when needed, don't ask permission\n`;
return prompt;
}
@ -71,7 +75,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
## What Rowboat Is
Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done.
**Email Drafting:** When users ask you to draft emails or respond to emails, load the \`draft-emails\` skill first. It provides structured guidance for processing emails, gathering context from calendar and knowledge base, and creating well-informed draft responses.
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first. Do NOT load this skill for reading, fetching, or checking emails — use Composio tools for that instead.
**Live Email/Calendar/Service Queries:** When users ask to **read**, **fetch**, **check**, or **view** emails, calendar events, or any data from a connected service (e.g., "what's my latest email?", "check my inbox", "what meetings do I have today?"), use \`composio-search-tools\` and \`composio-execute-tool\` to query the connected service directly. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders — use Composio for live data.
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
@ -212,13 +218,23 @@ Always consult this catalog first so you load the right skills before taking act
- Never start a response with a heading. Lead with a sentence or two of context first.
- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
## MCP Tool Discovery (CRITICAL)
## Tool Priority: Composio First, Then MCP
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
**When the user wants to interact with a third-party service (GitHub, Gmail, Slack, Notion, Jira, etc.):**
1. **FIRST** use the \`composio-*\` builtin tools — they are already authenticated and ready. Do NOT load the mcp-integration skill or draft-emails skill for service queries.
2. **ONLY** if the service is NOT available through Composio, fall back to MCP tools.
When a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, etc.), check MCP tools first using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for detailed guidance on discovering and executing MCP tools.
**Common Composio tasks (use composio-search-tools + composio-execute-tool):**
- "What's my latest email?" search "fetch emails" in gmail toolkit
- "Check my inbox" search "fetch emails" in gmail toolkit
- "What meetings do I have?" search "list events" in googlecalendar toolkit
- "Create a GitHub issue" search "create issue" in github toolkit
- "Send a Slack message" search "send message" in slack toolkit
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking MCP tools first!
**When the user wants capabilities that Composio does NOT cover** (web search, file scraping, audio generation, etc.):
- Check MCP tools using \`listMcpServers\` and \`listMcpTools\`. Load the "mcp-integration" skill for guidance.
**DO NOT** immediately respond with "I can't access the internet" or "I don't have that capability" without checking both Composio and MCP tools first!
## Execution Reminders
- Explore existing files and structure before creating new assets.
@ -258,7 +274,10 @@ ${runtimeContextPrompt}
- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
- **Composio tools** (\`composio-*\`) — External service integrations enabled by the user in Settings > Tools Library. These connect to third-party apps like Gmail, GitHub, Linear, Notion, etc. See the "Composio Integration Tools" section below for available tools.
- \`composio-list-toolkits\` — List available integrations (Gmail, Slack, GitHub, etc.) and their connection status
- \`composio-search-tools\` — Search for tools by use case (e.g., "send email", "create issue"); returns tool slugs and input schemas
- \`composio-execute-tool\` — Execute a Composio tool by slug with parameters from search results
- \`composio-connect-toolkit\` — Connect a service (Gmail, Slack, GitHub, etc.) via OAuth directly from chat
**Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
@ -306,8 +325,7 @@ let cachedInstructions: string | null = null;
/**
* Invalidate the cached instructions so the next buildCopilotInstructions() call
* regenerates the Composio tools section. Call this after enabling/disabling tools
* or connecting/disconnecting a toolkit.
* regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
*/
export function invalidateCopilotInstructionsCache(): void {
cachedInstructions = null;

View file

@ -3,9 +3,13 @@ export const skill = String.raw`
**Load this skill proactively** when a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, time/date, etc.). This skill provides complete guidance on discovering and executing MCP tools.
## CRITICAL: Always Check MCP Tools First
## CRITICAL: Composio Tools Take Priority Over MCP
**IMPORTANT**: When a user asks for ANY task that might require external capabilities (web search, API calls, data fetching, etc.), ALWAYS:
**If a Composio toolkit is connected for the service the user wants (GitHub, Gmail, Slack, etc.), use the \`composio-search-tools\` and \`composio-execute-tool\` builtin tools — NOT MCP tools.** Composio integrations are already authenticated and ready to use. Only fall back to MCP tools if the service is NOT available through Composio.
## When to Check MCP Tools
**IMPORTANT**: When a user asks for a task that requires external capabilities AND no Composio toolkit covers it, check MCP tools:
1. **First check**: Call \`listMcpServers\` to see what's available
2. **Then list tools**: Call \`listMcpTools\` on relevant servers
@ -23,9 +27,7 @@ export const skill = String.raw`
| "Read/write files" | filesystem | \`read_file\`, \`write_file\` |
| "Get current time/date" | time | \`get_current_time\` |
| "Make HTTP request" | fetch | \`fetch\`, \`post\` |
| "GitHub operations" | github | \`create_issue\`, \`search_repos\` |
| "Generate audio/speech" | elevenLabs | \`text_to_speech\` |
| "Tweet/social media" | twitter, composio | Various social tools |
## Key concepts
- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`.

View file

@ -13,11 +13,11 @@ import * as workspace from "../../workspace/workspace.js";
import { IAgentsRepo } from "../../agents/repo.js";
import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { composioEnabledToolsRepo } from "../../composio/enabled-tools-repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured } from "../../composio/client.js";
import { invalidateCopilotInstructionsCache } from "../assistant/instructions.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "../../composio/curated-toolkits.js";
import { getConnectionInitiator } from "../../composio/connection-bridge.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText, jsonSchema } from "ai";
import { generateText } from "ai";
import { createProvider } from "../../models/models.js";
import { IModelConfigRepo } from "../../models/repo.js";
import { isSignedIn } from "../../account/account.js";
@ -1177,93 +1177,174 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
};
},
},
// ========================================================================
// Composio Meta-Tools
// ========================================================================
'composio-list-toolkits': {
description: 'List available Composio integrations (Gmail, Slack, GitHub, etc.) and their connection status. Use this to show the user what services they can connect to.',
inputSchema: z.object({
category: z.enum(['all', 'communication', 'productivity', 'development', 'crm', 'social', 'storage', 'support']).optional()
.describe('Filter by category. Defaults to "all".'),
}),
execute: async ({ category }: { category?: string }) => {
const toolkits = CURATED_TOOLKITS
.filter(t => !category || category === 'all' || t.category === category)
.map(t => ({
slug: t.slug,
name: t.displayName,
category: t.category,
isConnected: composioAccountsRepo.isConnected(t.slug),
}));
const connectedCount = toolkits.filter(t => t.isConnected).length;
return {
toolkits,
connectedCount,
totalCount: toolkits.length,
};
},
isAvailable: async () => isComposioConfigured(),
},
'composio-search-tools': {
description: 'Search for Composio tools by use case across connected services. Returns tool slugs, descriptions, and input schemas so you can call composio-execute-tool with the right parameters. Example: search "send email" to find Gmail tools, "create issue" to find GitHub/Jira tools.',
inputSchema: z.object({
query: z.string().describe('Natural language description of what you want to do (e.g., "send an email", "create a GitHub issue", "schedule a meeting")'),
toolkitSlug: z.string().optional().describe('Optional: limit search to a specific toolkit (e.g., "gmail", "github")'),
}),
execute: async ({ query, toolkitSlug }: { query: string; toolkitSlug?: string }) => {
try {
const toolkitFilter = toolkitSlug ? [toolkitSlug] : undefined;
const result = await searchComposioTools(query, toolkitFilter);
// Filter to curated toolkits only (skip if a specific toolkit was requested —
// the API already filtered server-side)
const filtered = toolkitSlug
? result.items
: result.items.filter(t => CURATED_TOOLKIT_SLUGS.has(t.toolkitSlug));
// Annotate with connection status
const tools = filtered.map(t => ({
slug: t.slug,
name: t.name,
description: t.description,
toolkitSlug: t.toolkitSlug,
isConnected: composioAccountsRepo.isConnected(t.toolkitSlug),
inputSchema: t.inputParameters,
}));
return {
tools,
resultCount: tools.length,
hint: tools.some(t => !t.isConnected)
? 'Some tools require connecting the toolkit first. Use composio-connect-toolkit to help the user authenticate.'
: undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { tools: [], resultCount: 0, error: message };
}
},
isAvailable: async () => isComposioConfigured(),
},
'composio-execute-tool': {
description: 'Execute a Composio tool by its slug. You MUST pass the arguments field with all required parameters from the search results inputSchema. Example: composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })',
inputSchema: z.object({
toolSlug: z.string().describe('EXACT tool slug from search results (e.g., "GITHUB_ISSUES_LIST_FOR_REPO"). Copy it exactly — do not modify it.'),
toolkitSlug: z.string().describe('The toolkit slug (e.g., "gmail", "github")'),
arguments: z.record(z.string(), z.unknown()).describe('REQUIRED: Tool input parameters as key-value pairs. Get the required fields from the inputSchema returned by composio-search-tools. Never omit this.'),
}),
execute: async ({ toolSlug, toolkitSlug, arguments: args }: { toolSlug: string; toolkitSlug: string; arguments?: Record<string, unknown> }) => {
// Default arguments to {} if the LLM omits the field entirely
const toolArgs = args ?? {};
// Check connection
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
successful: false,
data: null,
error: `Toolkit "${toolkitSlug}" is not connected. Use composio-connect-toolkit to help the user connect it first.`,
};
}
try {
return await executeComposioAction(toolSlug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: toolArgs,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Composio] Tool execution failed for ${toolSlug}:`, message);
return {
successful: false,
data: null,
error: `Failed to execute ${toolSlug}: ${message}. If fields are missing, check the inputSchema and retry with the correct arguments.`,
};
}
},
isAvailable: async () => isComposioConfigured(),
},
'composio-connect-toolkit': {
description: 'Connect a Composio service (Gmail, Slack, GitHub, etc.) via OAuth. Opens the user\'s browser for authentication. After authenticating, the user can use tools from that service.',
inputSchema: z.object({
toolkitSlug: z.string().describe('The toolkit slug to connect (e.g., "gmail", "github", "slack", "notion")'),
}),
execute: async ({ toolkitSlug }: { toolkitSlug: string }) => {
// Validate against curated list
if (!CURATED_TOOLKIT_SLUGS.has(toolkitSlug)) {
const available = CURATED_TOOLKITS.map(t => `${t.slug} (${t.displayName})`).join(', ');
return {
success: false,
error: `Unknown toolkit "${toolkitSlug}". Available toolkits: ${available}`,
};
}
// Check if already connected
if (composioAccountsRepo.isConnected(toolkitSlug)) {
return {
success: true,
message: `${toolkitSlug} is already connected. You can search for and execute its tools.`,
alreadyConnected: true,
};
}
// Use the connection bridge to trigger OAuth
const initiator = getConnectionInitiator();
if (!initiator) {
return {
success: false,
error: 'Connection system not available. Please try connecting via Settings > Tools Library instead.',
};
}
try {
const result = await initiator(toolkitSlug);
if (result.success) {
const toolkit = CURATED_TOOLKITS.find(t => t.slug === toolkitSlug);
return {
success: true,
message: `Opening browser to authenticate with ${toolkit?.displayName ?? toolkitSlug}. Please complete the authentication in your browser, then let me know when you're done.`,
};
}
return {
success: false,
error: result.error || 'Failed to initiate connection',
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Connection failed: ${message}`,
};
}
},
isAvailable: async () => isComposioConfigured(),
},
};
// ============================================================================
// Dynamic Composio Tool Registration
// ============================================================================
const COMPOSIO_TOOL_PREFIX = 'composio-';
/**
* Unregister all dynamically registered Composio tools
*/
function unregisterComposioTools(): void {
for (const key of Object.keys(BuiltinTools)) {
if (key.startsWith(COMPOSIO_TOOL_PREFIX)) {
delete BuiltinTools[key];
}
}
}
/**
* Register enabled Composio tools as builtin tools.
* Each enabled tool gets a generic execute function that routes
* to the Composio API via the connected account.
*/
function registerComposioTools(): void {
const enabledTools = composioEnabledToolsRepo.getAll();
for (const [slug, tool] of Object.entries(enabledTools)) {
const toolKey = `${COMPOSIO_TOOL_PREFIX}${slug}`;
const toolkitSlug = tool.toolkitSlug;
const inputParams = tool.inputParameters ?? { type: 'object', properties: {} };
BuiltinTools[toolKey] = {
description: `[${tool.toolkitSlug}] ${tool.description}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inputSchema: jsonSchema({
type: 'object',
properties: (inputParams.properties ?? {}) as any,
...(inputParams.required ? { required: inputParams.required } : {}),
} as any) as unknown as ZodType,
execute: async (input: Record<string, unknown>) => {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
success: false,
error: `Toolkit "${toolkitSlug}" is not connected. Please connect it in Settings > Tools Library.`,
};
}
try {
return await executeComposioAction(slug, {
connected_account_id: account.id,
user_id: 'rowboat-user',
version: 'latest',
arguments: input,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Composio] Tool execution failed for ${slug}:`, message);
return {
success: false,
error: `Failed to execute ${slug}: ${message}`,
};
}
},
isAvailable: async () => {
return (await isComposioConfigured()) && composioAccountsRepo.isConnected(toolkitSlug);
},
};
}
const count = Object.keys(enabledTools).length;
if (count > 0) {
console.log(`[Composio] Registered ${count} dynamic tool(s)`);
}
}
/**
* Refresh dynamic Composio tools by unregistering all and re-registering from the repo.
* Called after enabling/disabling tools or disconnecting a toolkit.
* Also invalidates the cached agent instructions so they reflect the new tool set.
*/
export function refreshComposioTools(): void {
unregisterComposioTools();
registerComposioTools();
invalidateCopilotInstructionsCache();
}
// Register on module load
refreshComposioTools();

View file

@ -291,6 +291,79 @@ export async function deleteConnectedAccount(connectedAccountId: string): Promis
});
}
/**
* Schema for search results: includes toolkit info and full input_parameters.
*/
const ZSearchResultTool = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkit: z.object({
slug: z.string(),
name: z.string(),
logo: z.string(),
}).optional(),
input_parameters: z.object({
type: z.literal('object').optional().default('object'),
properties: z.record(z.string(), z.unknown()).optional().default({}),
required: z.array(z.string()).optional(),
}).optional().default({ type: 'object', properties: {} }),
}).passthrough();
/**
* Infer toolkit slug from a tool slug.
* Tool naming convention: TOOLKIT_ACTION (e.g., GITHUB_CREATE_ISSUE github).
* For multi-word toolkit slugs (GOOGLECALENDAR_CREATE_EVENT), the prefix before
* the first underscore matches the toolkit slug.
*/
function inferToolkitSlug(toolSlug: string): string {
// Tool slugs are uppercase: GITHUB_CREATE_ISSUE, GMAIL_SEND_EMAIL, etc.
// The toolkit prefix is everything before the first action word.
// Strategy: lowercase the slug and try progressively shorter prefixes.
const lower = toolSlug.toLowerCase();
const parts = lower.split('_');
// Try joining from 1 part up to all-but-1 to find a known prefix
// Most common: single-word prefix (github, gmail, slack)
return parts[0] ?? lower;
}
/**
* Search for tools across all toolkits (or optionally filtered by specific toolkit slugs).
* Returns tools with full input_parameters so the agent knows what params to pass.
*/
export async function searchTools(
searchQuery: string,
toolkitSlugs?: string[],
): Promise<{ items: Array<{ slug: string; name: string; description: string; toolkitSlug: string; inputParameters: { type: 'object'; properties: Record<string, unknown>; required?: string[] } }> }> {
const params: Record<string, string> = {
search: searchQuery,
limit: '15',
};
if (toolkitSlugs && toolkitSlugs.length === 1) {
params.toolkit_slug = toolkitSlugs[0];
}
const result = await composioApiCall(ZListResponse(ZSearchResultTool), "/tools", params);
const items = result.items.map((item) => ({
slug: item.slug,
name: item.name,
description: item.description,
// Use toolkit.slug from API response, fall back to inferring from tool slug,
// and finally fall back to the requested toolkit slug if one was provided.
toolkitSlug: item.toolkit?.slug
|| inferToolkitSlug(item.slug)
|| (toolkitSlugs?.length === 1 ? toolkitSlugs[0] : ''),
inputParameters: {
type: 'object' as const,
properties: item.input_parameters?.properties ?? {},
required: item.input_parameters?.required,
},
}));
return { items };
}
/**
* List available tools for a toolkit
*/
@ -308,54 +381,6 @@ export async function listToolkitTools(
return composioApiCall(ZListResponse(ZTool), "/tools", params);
}
/**
* Schema for the detailed tools response (preserves input_parameters).
* Uses passthrough so extra API fields don't cause validation failures.
*/
const ZDetailedTool = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
input_parameters: z.object({
type: z.literal('object').optional().default('object'),
properties: z.record(z.string(), z.unknown()).optional().default({}),
required: z.array(z.string()).optional(),
}).optional().default({ type: 'object', properties: {} }),
}).passthrough();
/**
* List available tools for a toolkit with full details including input_parameters.
* Uses composioApiCall for consistent error handling, logging, and validation.
*/
export async function listToolkitToolsDetailed(
toolkitSlug: string,
searchQuery: string | null = null,
): Promise<{ items: Array<{ slug: string; name: string; description: string; toolkitSlug: string; inputParameters: { type: 'object'; properties: Record<string, unknown>; required?: string[] } }> }> {
const params: Record<string, string> = {
toolkit_slug: toolkitSlug,
limit: '200',
};
if (searchQuery) {
params.search = searchQuery;
}
const result = await composioApiCall(ZListResponse(ZDetailedTool), "/tools", params);
return {
items: result.items.map((item) => ({
slug: item.slug,
name: item.name,
description: item.description,
toolkitSlug,
inputParameters: {
type: 'object' as const,
properties: item.input_parameters?.properties ?? {},
required: item.input_parameters?.required,
},
})),
};
}
/**
* Execute a tool action
*/

View file

@ -0,0 +1,33 @@
/**
* Connection bridge for Composio toolkit OAuth.
*
* Builtin tools run in the core package which cannot import Electron-specific
* code from the main process. This module provides a callback registry so the
* main process can register its `initiateConnection` function at startup, and
* builtin tools can call it at runtime.
*/
type ConnectionInitiator = (toolkitSlug: string) => Promise<{
success: boolean;
redirectUrl?: string;
connectedAccountId?: string;
error?: string;
}>;
let connectionInitiator: ConnectionInitiator | null = null;
/**
* Register the connection initiator callback.
* Called once by the main process at startup.
*/
export function setConnectionInitiator(fn: ConnectionInitiator): void {
connectionInitiator = fn;
}
/**
* Get the registered connection initiator.
* Returns null if not yet registered (app not fully initialized).
*/
export function getConnectionInitiator(): ConnectionInitiator | null {
return connectionInitiator;
}

View file

@ -0,0 +1,74 @@
/**
* Curated list of Composio toolkits available to Rowboat users.
* Only these toolkits are shown in the UI and discoverable via chat.
* Exact slugs match Composio API naming convention.
*
* Display names come from @x/shared/composio (single source of truth).
*/
import { COMPOSIO_DISPLAY_NAMES } from "@x/shared/dist/composio.js";
export { COMPOSIO_DISPLAY_NAMES } from "@x/shared/dist/composio.js";
export type ToolkitCategory = 'communication' | 'productivity' | 'development' | 'crm' | 'social' | 'storage' | 'support';
export interface CuratedToolkit {
slug: string;
displayName: string;
category: ToolkitCategory;
}
const toolkit = (slug: string, category: ToolkitCategory): CuratedToolkit => ({
slug,
displayName: COMPOSIO_DISPLAY_NAMES[slug] ?? slug,
category,
});
export const CURATED_TOOLKITS: CuratedToolkit[] = [
// Communication
toolkit('gmail', 'communication'),
toolkit('slack', 'communication'),
toolkit('microsoft_outlook', 'communication'),
toolkit('microsoft_teams', 'communication'),
// Productivity
toolkit('googlecalendar', 'productivity'),
toolkit('googledocs', 'productivity'),
toolkit('googlesheets', 'productivity'),
toolkit('notion', 'productivity'),
toolkit('airtable', 'productivity'),
toolkit('calendly', 'productivity'),
toolkit('cal', 'productivity'),
// Storage
toolkit('googledrive', 'storage'),
toolkit('dropbox', 'storage'),
toolkit('onedrive', 'storage'),
// Development
toolkit('github', 'development'),
toolkit('linear', 'development'),
toolkit('jira', 'development'),
// Project Management
toolkit('asana', 'productivity'),
toolkit('trello', 'productivity'),
// CRM & Sales
toolkit('hubspot', 'crm'),
toolkit('salesforce', 'crm'),
// Social
toolkit('linkedin', 'social'),
toolkit('twitter', 'social'),
toolkit('reddit', 'social'),
// Support
toolkit('intercom', 'support'),
toolkit('zendesk', 'support'),
];
/**
* Set of curated toolkit slugs for fast lookup.
*/
export const CURATED_TOOLKIT_SLUGS = new Set(CURATED_TOOLKITS.map(t => t.slug));

View file

@ -0,0 +1,32 @@
/**
* Composio display-name map: toolkit slug human-readable name.
* Single source of truth used by both core and renderer.
*/
export const COMPOSIO_DISPLAY_NAMES: Record<string, string> = {
gmail: 'Gmail',
slack: 'Slack',
microsoft_outlook: 'Microsoft Outlook',
microsoft_teams: 'Microsoft Teams',
googlecalendar: 'Google Calendar',
googledocs: 'Google Docs',
googlesheets: 'Google Sheets',
notion: 'Notion',
airtable: 'Airtable',
calendly: 'Calendly',
cal: 'Cal.com',
googledrive: 'Google Drive',
dropbox: 'Dropbox',
onedrive: 'OneDrive',
github: 'GitHub',
linear: 'Linear',
jira: 'Jira',
asana: 'Asana',
trello: 'Trello',
hubspot: 'HubSpot',
salesforce: 'Salesforce',
linkedin: 'LinkedIn',
twitter: 'X',
reddit: 'Reddit',
intercom: 'Intercom',
zendesk: 'Zendesk',
};

View file

@ -377,18 +377,6 @@ const ipcSchemas = {
toolkits: z.array(z.string()),
}),
},
'composio:execute-action': {
req: z.object({
actionSlug: z.string(),
toolkitSlug: z.string(),
input: z.record(z.string(), z.unknown()),
}),
res: z.object({
data: z.unknown(),
successful: z.boolean(),
error: z.string().nullable(),
}),
},
'composio:use-composio-for-google': {
req: z.null(),
res: z.object({
@ -432,58 +420,6 @@ const ipcSchemas = {
totalItems: z.number(),
}),
},
'composio:list-toolkit-tools': {
req: z.object({
toolkitSlug: z.string(),
search: z.string().optional(),
}),
res: z.object({
items: z.array(z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkitSlug: z.string(),
inputParameters: z.object({
type: z.string().optional(),
properties: z.record(z.string(), z.unknown()).optional(),
required: z.array(z.string()).optional(),
}).optional(),
})),
}),
},
'composio:get-enabled-tools': {
req: z.null(),
res: z.object({
tools: z.record(z.string(), z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkitSlug: z.string(),
})),
}),
},
'composio:enable-tools': {
req: z.object({
tools: z.array(z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkitSlug: z.string(),
inputParameters: z.object({
type: z.string().optional(),
properties: z.record(z.string(), z.unknown()).optional(),
required: z.array(z.string()).optional(),
}).optional(),
})),
}),
res: z.object({ success: z.boolean() }),
},
'composio:disable-tools': {
req: z.object({
toolSlugs: z.array(z.string()),
}),
res: z.object({ success: z.boolean() }),
},
// Agent schedule channels
'agent-schedule:getConfig': {
req: z.null(),