first version of composio

This commit is contained in:
tusharmagar 2026-03-06 14:21:11 +05:30
parent e8a666499a
commit 04e094a299
10 changed files with 1061 additions and 7 deletions

View file

@ -2,7 +2,10 @@ 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 } from '@x/core/dist/composio/types.js';
import { refreshComposioTools } from '@x/core/dist/application/lib/builtin-tools.js';
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
@ -249,11 +252,16 @@ export async function disconnect(toolkitSlug: string): Promise<{ success: boolea
// 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
composioAccountsRepo.deleteAccount(toolkitSlug);
composioEnabledToolsRepo.disableAllForToolkit(toolkitSlug);
refreshComposioTools();
return { success: true };
}
}
@ -294,3 +302,95 @@ export async function executeAction(
};
}
}
/**
* List available Composio toolkits
*/
export async function listToolkits(cursor?: string): Promise<{
items: Array<{
slug: string;
name: string;
meta: { description: string; logo: string; tools_count: number; triggers_count: number };
no_auth: boolean;
auth_schemes: string[];
composio_managed_auth_schemes: string[];
}>;
nextCursor: string | null;
totalItems: number;
}> {
const result = await composioClient.listToolkits(cursor || null);
return {
items: result.items,
nextCursor: result.next_cursor,
totalItems: result.total_items,
};
}
/**
* 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

@ -443,6 +443,22 @@ export function setupIpcHandlers() {
'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);
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronDown, ChevronRight, Check, Link2, Unlink } from "lucide-react"
import {
Dialog,
@ -22,7 +22,7 @@ import { cn } from "@/lib/utils"
import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
type ConfigTab = "models" | "mcp" | "security" | "appearance"
type ConfigTab = "models" | "mcp" | "security" | "appearance" | "tools"
interface TabConfig {
id: ConfigTab
@ -60,6 +60,12 @@ const tabs: TabConfig[] = [
icon: Palette,
description: "Customize the look and feel",
},
{
id: "tools",
label: "Tools Library",
icon: Wrench,
description: "Browse and enable Composio toolkits",
},
]
interface SettingsDialogProps {
@ -685,6 +691,540 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Tools Library Settings ---
interface ToolkitInfo {
slug: string
name: string
meta: { description: string; logo: string; tools_count: number; triggers_count: number }
no_auth: boolean
auth_schemes: string[]
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)
const [apiKeyInput, setApiKeyInput] = useState("")
const [apiKeySaving, setApiKeySaving] = useState(false)
const [showApiKeyInput, setShowApiKeyInput] = useState(false)
// Toolkit browsing state
const [toolkits, setToolkits] = useState<ToolkitInfo[]>([])
const [toolkitsLoading, setToolkitsLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
// Connection state
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())
// Check API key configuration
const checkApiKey = useCallback(async () => {
try {
const result = await window.ipc.invoke("composio:is-configured", null)
setApiKeyConfigured(result.configured)
if (!result.configured) {
setShowApiKeyInput(true)
}
} catch {
setApiKeyConfigured(false)
}
}, [])
// Load connected toolkits
const loadConnected = useCallback(async () => {
try {
const result = await window.ipc.invoke("composio:list-connected", null)
setConnectedToolkits(new Set(result.toolkits))
} catch {
// ignore
}
}, [])
// 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)
try {
const result = await window.ipc.invoke("composio:list-toolkits", {})
setToolkits(result.items)
} catch {
toast.error("Failed to load toolkits")
} finally {
setToolkitsLoading(false)
}
}, [])
// Initial load
useEffect(() => {
if (!dialogOpen) return
checkApiKey()
loadConnected()
loadEnabledTools()
}, [dialogOpen, checkApiKey, loadConnected, loadEnabledTools])
// Load toolkits when API key is configured
useEffect(() => {
if (dialogOpen && apiKeyConfigured) {
loadToolkits()
}
}, [dialogOpen, apiKeyConfigured, loadToolkits])
// Listen for composio connection events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
setConnectingToolkit(null)
if (success) {
setConnectedToolkits(prev => new Set([...prev, toolkitSlug]))
toast.success(`Connected to ${toolkitSlug}`)
} else {
toast.error(error || `Failed to connect to ${toolkitSlug}`)
}
})
return cleanup
}, [])
// Save API key
const handleSaveApiKey = async () => {
const trimmed = apiKeyInput.trim()
if (!trimmed) return
setApiKeySaving(true)
try {
const result = await window.ipc.invoke("composio:set-api-key", { apiKey: trimmed })
if (result.success) {
setApiKeyConfigured(true)
setShowApiKeyInput(false)
setApiKeyInput("")
toast.success("Composio API key saved")
} else {
toast.error(result.error || "Failed to save API key")
}
} catch {
toast.error("Failed to save API key")
} finally {
setApiKeySaving(false)
}
}
// Connect a toolkit
const handleConnect = async (toolkitSlug: string) => {
setConnectingToolkit(toolkitSlug)
try {
const result = await window.ipc.invoke("composio:initiate-connection", { toolkitSlug })
if (!result.success) {
toast.error(result.error || "Failed to connect")
setConnectingToolkit(null)
}
// Success will be handled by composio:didConnect event
} catch {
toast.error("Failed to connect")
setConnectingToolkit(null)
}
}
// Disconnect a toolkit
const handleDisconnect = async (toolkitSlug: string) => {
try {
await window.ipc.invoke("composio:disconnect", { toolkitSlug })
setConnectedToolkits(prev => {
const next = new Set(prev)
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)
}
}
// 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 =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.meta.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: toolkits
return (
<div className="space-y-4">
{/* Section A: API Key */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Composio API Key</span>
{apiKeyConfigured && !showApiKeyInput ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
API key configured
</div>
<button
onClick={() => setShowApiKeyInput(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Change
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Enter your Composio API key to browse and enable tool integrations.
Get your key from{" "}
<a
href="https://app.composio.dev/settings"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
app.composio.dev/settings
</a>
</p>
<div className="flex gap-2">
<Input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="Paste your Composio API key"
onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()}
className="flex-1"
/>
<Button
onClick={handleSaveApiKey}
disabled={!apiKeyInput.trim() || apiKeySaving}
size="sm"
>
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : "Save"}
</Button>
{apiKeyConfigured && (
<Button
variant="outline"
size="sm"
onClick={() => { setShowApiKeyInput(false); setApiKeyInput("") }}
>
Cancel
</Button>
)}
</div>
</div>
)}
</div>
{/* Section B: Toolkit Browser (only when API key configured) */}
{apiKeyConfigured && (
<>
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Available Toolkits</span>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search toolkits..."
className="pl-8"
/>
</div>
</div>
{toolkitsLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading toolkits...
</div>
) : (
<div className="space-y-1.5 max-h-[340px] overflow-y-auto pr-1">
{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
return (
<div key={toolkit.slug} className="border rounded-md overflow-hidden">
{/* 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"
)}
>
{/* Logo */}
{toolkit.meta.logo ? (
<img
src={toolkit.meta.logo}
alt=""
className="size-7 rounded object-contain flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : (
<div className="size-7 rounded bg-muted flex items-center justify-center flex-shrink-0">
<Wrench className="size-3.5 text-muted-foreground" />
</div>
)}
{/* Name & description */}
<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">
{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">
{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" />
) : (
<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 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={(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>
)}
{/* 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 list (only if connected) */}
{isConnected && (
<div className="space-y-0.5">
{isLoadingTools ? (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Loading tools...
</div>
) : tools.length === 0 ? (
<p className="text-xs text-muted-foreground py-1">No tools found</p>
) : (
<div className="max-h-[200px] overflow-y-auto space-y-0.5">
{tools.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 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"
)}
>
{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-1">
{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>
)}
</div>
)
})}
{filteredToolkits.length === 0 && !toolkitsLoading && (
<div className="text-center py-6 text-sm text-muted-foreground">
{searchQuery ? "No toolkits match your search" : "No toolkits available"}
</div>
)}
</div>
)}
</>
)}
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
@ -814,11 +1354,13 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools") ? "overflow-y-auto" : "overflow-hidden")}>
{activeTab === "models" ? (
<ModelSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "tools" ? (
<ToolsLibrarySettings dialogOpen={open} />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...

View file

@ -12,6 +12,7 @@ import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { CopilotAgent } from "../application/assistant/agent.js";
import { buildCopilotInstructions } from "../application/assistant/instructions.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
@ -312,7 +313,15 @@ function formatLlmStreamError(rawError: unknown): string {
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
if (id === "copilot" || id === "rowboatx") {
return CopilotAgent;
// Rebuild tools from current BuiltinTools to pick up dynamically
// registered Composio tools (added via refreshComposioTools).
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
tools[name] = { type: "builtin", name };
}
// Rebuild instructions to include current Composio tools section
const instructions = buildCopilotInstructions();
return { ...CopilotAgent, tools, instructions };
}
if (id === 'note_creation') {

View file

@ -1,9 +1,56 @@
import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../../config/config.js";
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";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
/**
* Generate dynamic instructions section for Composio tools.
* Returns empty string if no tools are enabled.
*/
function getComposioToolsPrompt(): string {
if (!isComposioConfigured()) return '';
const enabledTools = composioEnabledToolsRepo.getAll();
const toolEntries = Object.values(enabledTools);
if (toolEntries.length === 0) return '';
// 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);
}
// Check which toolkits are connected
const connectedToolkits = new Set(composioAccountsRepo.getConnectedToolkits());
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`;
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`;
}
return prompt;
}
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor.
@ -184,6 +231,7 @@ ${runtimeContextPrompt}
- \`loadSkill\` - Skill loading
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
- \`web-search\` and \`research-search\` - Web and research search tools (available when configured). **You MUST load the \`web-search\` skill before using either of these tools.** It tells you which tool to pick and how many searches to do.
- **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.
**Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
@ -223,3 +271,13 @@ This renders as an interactive card in the UI that the user can click to open th
**IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully.
Never output raw file paths in plain text when they could be wrapped in a filepath block unless the file does not exist yet.`;
/**
* Build full copilot instructions with dynamic Composio tools section.
* Called each time the agent is loaded to reflect currently enabled tools.
*/
export function buildCopilotInstructions(): string {
const composioPrompt = getComposioToolsPrompt();
if (!composioPrompt) return CopilotInstructions;
return CopilotInstructions + '\n' + composioPrompt;
}

View file

@ -13,10 +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, listToolkitTools } from "../../composio/client.js";
import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
import { generateText, jsonSchema } from "ai";
import { createProvider } from "../../models/models.js";
import { IModelConfigRepo } from "../../models/repo.js";
// Parser libraries are loaded dynamically inside parseFile.execute()
@ -1478,3 +1479,76 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
};
// ============================================================================
// 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.`,
};
}
return executeComposioAction(slug, account.id, input);
},
isAvailable: async () => {
return 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.
*/
export function refreshComposioTools(): void {
unregisterComposioTools();
registerComposioTools();
}
// Register on module load
refreshComposioTools();

View file

@ -330,6 +330,55 @@ export async function listToolkitTools(
};
}
/**
* List available tools for a toolkit with full details including input_parameters
*/
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 apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
const url = new URL(`${BASE_URL}/tools`);
url.searchParams.set('toolkit_slug', toolkitSlug);
url.searchParams.set('limit', '200');
if (searchQuery) {
url.searchParams.set('search', searchQuery);
}
console.log(`[Composio] Listing tools (detailed) for toolkit: ${toolkitSlug}`);
const response = await fetch(url.toString(), {
headers: { "x-api-key": apiKey },
});
if (!response.ok) {
throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`);
}
const data = await response.json() as { items?: Array<Record<string, unknown>> };
return {
items: (data.items || []).map((item) => {
const inputParams = item.input_parameters as Record<string, unknown> | undefined;
return {
slug: String(item.slug ?? ''),
name: String(item.name ?? ''),
description: String(item.description ?? ''),
toolkitSlug,
inputParameters: {
type: 'object' as const,
properties: (inputParams?.properties as Record<string, unknown>) ?? {},
required: Array.isArray(inputParams?.required) ? inputParams.required as string[] : undefined,
},
};
}),
};
}
/**
* Execute a tool action using Composio SDK
*/

View file

@ -0,0 +1,129 @@
import fs from "fs";
import path from "path";
import { z } from "zod";
import { WorkDir } from "../config/config.js";
const ENABLED_TOOLS_FILE = path.join(WorkDir, 'data', 'composio', 'enabled_tools.json');
/**
* Schema for an enabled Composio tool
*/
export const ZEnabledTool = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkitSlug: z.string(),
inputParameters: 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: {} }),
});
export type EnabledTool = z.infer<typeof ZEnabledTool>;
/**
* Schema for the enabled tools storage file
*/
const ZEnabledToolsStorage = z.object({
tools: z.record(z.string(), ZEnabledTool), // keyed by tool slug
});
type EnabledToolsStorage = z.infer<typeof ZEnabledToolsStorage>;
/**
* Interface for Composio enabled tools repository
*/
export interface IComposioEnabledToolsRepo {
getAll(): Record<string, EnabledTool>;
getByToolkit(toolkitSlug: string): EnabledTool[];
enable(tool: EnabledTool): void;
enableBatch(tools: EnabledTool[]): void;
disable(toolSlug: string): void;
disableBatch(toolSlugs: string[]): void;
disableAllForToolkit(toolkitSlug: string): void;
isEnabled(toolSlug: string): boolean;
}
function ensureStorageDir(): void {
const dir = path.dirname(ENABLED_TOOLS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function loadStorage(): EnabledToolsStorage {
try {
if (fs.existsSync(ENABLED_TOOLS_FILE)) {
const data = fs.readFileSync(ENABLED_TOOLS_FILE, 'utf-8');
return ZEnabledToolsStorage.parse(JSON.parse(data));
}
} catch (error) {
console.error('[ComposioEnabledTools] Failed to load storage:', error);
}
return { tools: {} };
}
function saveStorage(storage: EnabledToolsStorage): void {
ensureStorageDir();
fs.writeFileSync(ENABLED_TOOLS_FILE, JSON.stringify(storage, null, 2));
}
/**
* Repository for managing enabled Composio tools
*/
export class ComposioEnabledToolsRepo implements IComposioEnabledToolsRepo {
getAll(): Record<string, EnabledTool> {
return loadStorage().tools;
}
getByToolkit(toolkitSlug: string): EnabledTool[] {
const storage = loadStorage();
return Object.values(storage.tools).filter(t => t.toolkitSlug === toolkitSlug);
}
enable(tool: EnabledTool): void {
const storage = loadStorage();
storage.tools[tool.slug] = tool;
saveStorage(storage);
}
enableBatch(tools: EnabledTool[]): void {
const storage = loadStorage();
for (const tool of tools) {
storage.tools[tool.slug] = tool;
}
saveStorage(storage);
}
disable(toolSlug: string): void {
const storage = loadStorage();
delete storage.tools[toolSlug];
saveStorage(storage);
}
disableBatch(toolSlugs: string[]): void {
const storage = loadStorage();
for (const slug of toolSlugs) {
delete storage.tools[slug];
}
saveStorage(storage);
}
disableAllForToolkit(toolkitSlug: string): void {
const storage = loadStorage();
for (const [slug, tool] of Object.entries(storage.tools)) {
if (tool.toolkitSlug === toolkitSlug) {
delete storage.tools[slug];
}
}
saveStorage(storage);
}
isEnabled(toolSlug: string): boolean {
const storage = loadStorage();
return toolSlug in storage.tools;
}
}
export const composioEnabledToolsRepo = new ComposioEnabledToolsRepo();

View file

@ -46,8 +46,10 @@ export const ZToolkit = z.object({
name: z.string(),
meta: ZToolkitMeta,
no_auth: z.boolean(),
auth_schemes: z.array(ZAuthScheme),
composio_managed_auth_schemes: z.array(ZAuthScheme),
// Use z.string() instead of ZAuthScheme to be resilient against
// new auth types added by the Composio API over time.
auth_schemes: z.array(z.string()),
composio_managed_auth_schemes: z.array(z.string()),
});
/**

View file

@ -362,6 +362,81 @@ const ipcSchemas = {
}),
res: z.null(),
},
// Composio Tools Library channels
'composio:list-toolkits': {
req: z.object({
cursor: z.string().optional(),
}),
res: z.object({
items: z.array(z.object({
slug: z.string(),
name: z.string(),
meta: z.object({
description: z.string(),
logo: z.string(),
tools_count: z.number(),
triggers_count: z.number(),
}),
no_auth: z.boolean(),
auth_schemes: z.array(z.string()),
composio_managed_auth_schemes: z.array(z.string()),
})),
nextCursor: z.string().nullable(),
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(),