slack integration to allow users to ask copilot to use slack on their behalf

This commit is contained in:
tusharmagar 2026-01-31 11:52:22 +05:30
parent d04a9cfa84
commit 9bd1009a26
18 changed files with 2310 additions and 75 deletions

View file

@ -4,6 +4,16 @@ import { URL } from 'url';
const OAUTH_CALLBACK_PATH = '/oauth/callback';
const DEFAULT_PORT = 8080;
/** Escape HTML special characters to prevent XSS */
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export interface AuthServerResult {
server: Server;
port: number;
@ -15,7 +25,7 @@ export interface AuthServerResult {
*/
export function createAuthServer(
port: number = DEFAULT_PORT,
onCallback: (code: string, state: string) => void
onCallback: (code: string, state: string) => void | Promise<void>
): Promise<AuthServerResult> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
@ -46,7 +56,7 @@ export function createAuthServer(
</head>
<body>
<h1 class="error">Authorization Failed</h1>
<p>Error: ${error}</p>
<p>Error: ${escapeHtml(error)}</p>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
@ -55,48 +65,28 @@ export function createAuthServer(
return;
}
if (code && state) {
onCallback(code, state);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.success { color: #2e7d32; }
</style>
</head>
<body>
<h1 class="success">Authorization Successful</h1>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
} else {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1 class="error">Invalid Request</h1>
<p>Missing code or state parameter.</p>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
}
// Handle callback - either traditional OAuth with code/state or Composio-style notification
// Composio callbacks may not have code/state, just a notification that the flow completed
onCallback(code || '', state || '');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.success { color: #2e7d32; }
</style>
</head>
<body>
<h1 class="success">Authorization Successful</h1>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
} else {
res.writeHead(404);
res.end('Not Found');

View file

@ -0,0 +1,296 @@
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 type { LocalConnectedAccount } from '@x/core/dist/composio/types.js';
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
// Store active OAuth flows
const activeFlows = new Map<string, {
toolkitSlug: string;
connectedAccountId: string;
authConfigId: string;
}>();
/**
* Emit Composio connection event to all renderer windows
*/
export function emitComposioEvent(event: { toolkitSlug: string; success: boolean; error?: string }): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('composio:didConnect', event);
}
}
}
/**
* Check if Composio is configured with an API key
*/
export function isConfigured(): { configured: boolean } {
return { configured: composioClient.isConfigured() };
}
/**
* Set the Composio API key
*/
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
try {
composioClient.setApiKey(apiKey);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set API key',
};
}
}
/**
* Initiate OAuth connection for a toolkit
*/
export async function initiateConnection(toolkitSlug: string): Promise<{
success: boolean;
redirectUrl?: string;
connectedAccountId?: string;
error?: string;
}> {
try {
console.log(`[Composio] Initiating connection for ${toolkitSlug}...`);
// Check if already connected
if (composioAccountsRepo.isConnected(toolkitSlug)) {
return { success: true };
}
// Get toolkit to check auth schemes
const toolkit = await composioClient.getToolkit(toolkitSlug);
// Check for managed OAuth2
if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) {
return {
success: false,
error: `Toolkit ${toolkitSlug} does not support managed OAuth2`,
};
}
// Find or create managed OAuth2 auth config
const authConfigs = await composioClient.listAuthConfigs(toolkitSlug, null, true);
let authConfigId: string;
const managedOauth2 = authConfigs.items.find(
cfg => cfg.auth_scheme === 'OAUTH2' && cfg.is_composio_managed
);
if (managedOauth2) {
authConfigId = managedOauth2.id;
} else {
// Create new managed auth config
const created = await composioClient.createAuthConfig({
toolkit: { slug: toolkitSlug },
auth_config: {
type: 'use_composio_managed_auth',
name: `rowboat-${toolkitSlug}`,
},
});
authConfigId = created.auth_config.id;
}
// Create connected account with callback URL
const callbackUrl = REDIRECT_URI;
const response = await composioClient.createConnectedAccount({
auth_config: { id: authConfigId },
connection: {
user_id: 'rowboat-user',
callback_url: callbackUrl,
},
});
const connectedAccountId = response.id;
// Safely extract redirectUrl with type checking
const connectionVal = response.connectionData?.val;
const redirectUrl = typeof connectionVal === 'object' && connectionVal !== null && 'redirectUrl' in connectionVal
? String((connectionVal as Record<string, unknown>).redirectUrl)
: undefined;
if (!redirectUrl) {
return {
success: false,
error: 'No redirect URL received from Composio',
};
}
// Store flow state
const flowKey = `${toolkitSlug}-${Date.now()}`;
activeFlows.set(flowKey, {
toolkitSlug,
connectedAccountId,
authConfigId,
});
// Save initial account state
const account: LocalConnectedAccount = {
id: connectedAccountId,
authConfigId,
status: 'INITIATED',
toolkitSlug,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
composioAccountsRepo.saveAccount(account);
// Set up callback server
let cleanupTimeout: NodeJS.Timeout;
const { server } = await createAuthServer(8081, async (_code, _state) => {
// OAuth callback received - sync the account status
try {
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
if (accountStatus.status === 'ACTIVE') {
emitComposioEvent({ toolkitSlug, success: true });
} else {
emitComposioEvent({
toolkitSlug,
success: false,
error: `Connection status: ${accountStatus.status}`,
});
}
} catch (error) {
console.error('[Composio] Failed to sync account status:', error);
emitComposioEvent({
toolkitSlug,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
activeFlows.delete(flowKey);
server.close();
clearTimeout(cleanupTimeout);
}
});
// Timeout for abandoned flows (5 minutes)
cleanupTimeout = setTimeout(() => {
if (activeFlows.has(flowKey)) {
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
activeFlows.delete(flowKey);
server.close();
emitComposioEvent({
toolkitSlug,
success: false,
error: 'OAuth flow timed out',
});
}
}, 5 * 60 * 1000);
// Open browser for OAuth
shell.openExternal(redirectUrl);
return {
success: true,
redirectUrl,
connectedAccountId,
};
} catch (error) {
console.error('[Composio] Connection initiation failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Get connection status for a toolkit
*/
export async function getConnectionStatus(toolkitSlug: string): Promise<{
isConnected: boolean;
status?: string;
}> {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account) {
return { isConnected: false };
}
return {
isConnected: account.status === 'ACTIVE',
status: account.status,
};
}
/**
* Sync connection status with Composio API
*/
export async function syncConnection(
toolkitSlug: string,
connectedAccountId: string
): Promise<{ status: string }> {
try {
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
return { status: accountStatus.status };
} catch (error) {
console.error('[Composio] Failed to sync connection:', error);
return { status: 'FAILED' };
}
}
/**
* Disconnect a toolkit
*/
export async function disconnect(toolkitSlug: string): Promise<{ success: boolean }> {
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (account) {
// Delete from Composio
await composioClient.deleteConnectedAccount(account.id);
// Delete local record
composioAccountsRepo.deleteAccount(toolkitSlug);
}
return { success: true };
} catch (error) {
console.error('[Composio] Disconnect failed:', error);
// Still delete local record even if API call fails
composioAccountsRepo.deleteAccount(toolkitSlug);
return { success: true };
}
}
/**
* List connected toolkits
*/
export function listConnected(): { toolkits: string[] } {
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
}
/**
* Execute a Composio action
*/
export async function executeAction(
actionSlug: string,
toolkitSlug: string,
input: Record<string, unknown>
): Promise<{ success: boolean; data: unknown; error?: string }> {
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
success: false,
data: null,
error: `Toolkit ${toolkitSlug} is not connected`,
};
}
const result = await composioClient.executeAction(actionSlug, account.id, input);
return result;
} catch (error) {
console.error('[Composio] Action execution failed:', error);
return {
success: false,
data: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

View file

@ -15,11 +15,12 @@ import { bus } from '@x/core/dist/runs/bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import z from 'zod';
import { RunEvent } from 'packages/shared/dist/runs.js';
import { RunEvent } from '@x/shared/dist/runs.js';
import container from '@x/core/dist/di/container.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -344,5 +345,30 @@ export function setupIpcHandlers() {
markOnboardingComplete();
return { success: true };
},
// Composio integration handlers
'composio:is-configured': async () => {
return composioHandler.isConfigured();
},
'composio:set-api-key': async (_event, args) => {
return composioHandler.setApiKey(args.apiKey);
},
'composio:initiate-connection': async (_event, args) => {
return composioHandler.initiateConnection(args.toolkitSlug);
},
'composio:get-connection-status': async (_event, args) => {
return composioHandler.getConnectionStatus(args.toolkitSlug);
},
'composio:sync-connection': async (_event, args) => {
return composioHandler.syncConnection(args.toolkitSlug, args.connectedAccountId);
},
'composio:disconnect': async (_event, args) => {
return composioHandler.disconnect(args.toolkitSlug);
},
'composio:list-connected': async () => {
return composioHandler.listConnected();
},
'composio:execute-action': async (_event, args) => {
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
},
});
}

View file

@ -0,0 +1,94 @@
"use client"
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface ComposioApiKeyModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (apiKey: string) => void
isSubmitting?: boolean
}
export function ComposioApiKeyModal({
open,
onOpenChange,
onSubmit,
isSubmitting = false,
}: ComposioApiKeyModalProps) {
const [apiKey, setApiKey] = useState("")
useEffect(() => {
if (!open) {
setApiKey("")
}
}, [open])
const trimmedApiKey = apiKey.trim()
const isValid = trimmedApiKey.length > 0
const handleSubmit = () => {
if (!isValid || isSubmitting) return
onSubmit(trimmedApiKey)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Enter Composio API Key</DialogTitle>
<DialogDescription>
Get your API key from{" "}
<a
href="https://app.composio.dev/settings"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
app.composio.dev/settings
</a>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="composio-api-key">
API Key
</label>
<Input
id="composio-api-key"
type="password"
placeholder="Enter your Composio API key"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
autoFocus
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
Continue
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail } from "lucide-react"
import { Loader2, Mic, Mail, MessageSquare } from "lucide-react"
import {
Popover,
@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
import { toast } from "sonner"
@ -43,6 +44,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
// Load available providers on mount
useEffect(() => {
async function loadProviders() {
@ -89,11 +96,89 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
}, [])
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
} finally {
setSlackLoading(false)
}
}, [])
// Connect to Slack via Composio
const startSlackConnect = useCallback(async () => {
try {
setSlackConnecting(true)
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
if (!result.success) {
toast.error(result.error || 'Failed to connect to Slack')
setSlackConnecting(false)
}
// Success will be handled by composio:didConnect event
} catch (error) {
console.error('Failed to connect to Slack:', error)
toast.error('Failed to connect to Slack')
setSlackConnecting(false)
}
}, [])
// Handle Slack connect button click
const handleConnectSlack = useCallback(async () => {
// Check if Composio is configured
const configResult = await window.ipc.invoke('composio:is-configured', null)
if (!configResult.configured) {
setComposioApiKeyOpen(true)
return
}
await startSlackConnect()
}, [startSlackConnect])
// Handle Composio API key submission
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
try {
await window.ipc.invoke('composio:set-api-key', { apiKey })
setComposioApiKeyOpen(false)
toast.success('Composio API key saved')
// Now start the Slack connection
await startSlackConnect()
} catch (error) {
console.error('Failed to save Composio API key:', error)
toast.error('Failed to save API key')
}
}, [startSlackConnect])
// Disconnect from Slack
const handleDisconnectSlack = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' })
if (result.success) {
setSlackConnected(false)
toast.success('Disconnected from Slack')
} else {
toast.error('Failed to disconnect from Slack')
}
} catch (error) {
console.error('Failed to disconnect from Slack:', error)
toast.error('Failed to disconnect from Slack')
} finally {
setSlackLoading(false)
}
}, [])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
// Refresh Granola
refreshGranolaConfig()
// Refresh Slack status
refreshSlackStatus()
// Refresh OAuth providers
if (providers.length === 0) return
@ -120,7 +205,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
)
setProviderStates(newStates)
}, [providers, refreshGranolaConfig])
}, [providers, refreshGranolaConfig, refreshSlackStatus])
// Refresh statuses when popover opens or providers list changes
useEffect(() => {
@ -164,6 +249,26 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
return cleanup
}, [refreshAllStatuses])
// Listen for Composio connection events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
if (toolkitSlug === 'slack') {
setSlackConnected(success)
setSlackConnecting(false)
if (success) {
toast.success('Connected to Slack')
} else {
toast.error(error || 'Failed to connect to Slack')
}
}
})
return cleanup
}, [])
const startConnect = useCallback(async (provider: string, clientId?: string) => {
setProviderStates(prev => ({
...prev,
@ -401,11 +506,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
{/* Fireflies */}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
<Separator className="my-2" />
{/* Team Communication Section - Slack */}
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
</div>
{/* Slack */}
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0">
{slackLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : slackConnected ? (
<Button
variant="outline"
size="sm"
onClick={handleDisconnectSlack}
className="h-7 px-2 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectSlack}
disabled={slackConnecting}
className="h-7 px-2 text-xs"
>
{slackConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
<ComposioApiKeyModal
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
/>
</>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react"
import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react"
import {
Dialog,
@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
import { toast } from "sonner"
@ -44,6 +45,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
// Track connected providers for the completion step
const connectedProviders = Object.entries(providerStates)
.filter(([, state]) => state.isConnected)
@ -97,11 +104,70 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
}, [])
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
} finally {
setSlackLoading(false)
}
}, [])
// Start Slack connection
const startSlackConnect = useCallback(async () => {
try {
setSlackConnecting(true)
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
if (!result.success) {
toast.error(result.error || 'Failed to connect to Slack')
setSlackConnecting(false)
}
// Success will be handled by composio:didConnect event
} catch (error) {
console.error('Failed to connect to Slack:', error)
toast.error('Failed to connect to Slack')
setSlackConnecting(false)
}
}, [])
// Connect to Slack via Composio (checks if configured first)
const handleConnectSlack = useCallback(async () => {
// Check if Composio is configured
const configResult = await window.ipc.invoke('composio:is-configured', null)
if (!configResult.configured) {
setComposioApiKeyOpen(true)
return
}
await startSlackConnect()
}, [startSlackConnect])
// Handle Composio API key submission
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
try {
await window.ipc.invoke('composio:set-api-key', { apiKey })
setComposioApiKeyOpen(false)
toast.success('Composio API key saved')
// Now start the Slack connection
await startSlackConnect()
} catch (error) {
console.error('Failed to save Composio API key:', error)
toast.error('Failed to save API key')
}
}, [startSlackConnect])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
// Refresh Granola
refreshGranolaConfig()
// Refresh Slack status
refreshSlackStatus()
// Refresh OAuth providers
if (providers.length === 0) return
@ -128,7 +194,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)
setProviderStates(newStates)
}, [providers, refreshGranolaConfig])
}, [providers, refreshGranolaConfig, refreshSlackStatus])
// Refresh statuses when modal opens or providers list changes
useEffect(() => {
@ -162,6 +228,26 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
return cleanup
}, [])
// Listen for Composio connection events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
if (toolkitSlug === 'slack') {
setSlackConnected(success)
setSlackConnecting(false)
if (success) {
toast.success('Connected to Slack')
} else {
toast.error(error || 'Failed to connect to Slack')
}
}
})
return cleanup
}, [])
const startConnect = useCallback(async (provider: string, clientId?: string) => {
setProviderStates(prev => ({
...prev,
@ -314,6 +400,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
)
// Render Slack row
const renderSlackRow = () => (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0">
{slackLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : slackConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
<span>Connected</span>
</div>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectSlack}
disabled={slackConnecting}
>
{slackConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
// Step 0: Welcome
const WelcomeStep = () => (
<div className="flex flex-col items-center text-center">
@ -381,6 +511,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
{renderGranolaRow()}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
</div>
{/* Team Communication Section */}
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
</div>
{renderSlackRow()}
</div>
</>
)}
</div>
@ -398,7 +536,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Step 2: Completion
const CompletionStep = () => {
const hasConnections = connectedProviders.length > 0 || granolaEnabled
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected
return (
<div className="flex flex-col items-center text-center">
@ -439,6 +577,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<span>Granola (Local meeting notes)</span>
</div>
)}
{slackConnected && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Slack (Team communication)</span>
</div>
)}
</div>
</div>
</div>
@ -459,6 +603,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
onSubmit={handleGoogleClientIdSubmit}
isSubmitting={providerStates.google?.isConnecting ?? false}
/>
<ComposioApiKeyModal
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
/>
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"

View file

@ -12,6 +12,7 @@
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/google": "^2.0.25",
"@ai-sdk/openai": "^2.0.53",
"@composio/core": "^0.1.48",
"@ai-sdk/openai-compatible": "^1.0.27",
"@ai-sdk/provider": "^2.0.0",
"@google-cloud/local-auth": "^3.0.1",

View file

@ -28,6 +28,8 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending.
## Memory That Compounds
Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic.
@ -163,6 +165,7 @@ When a user asks for ANY task that might require external capabilities (web sear
- \`analyzeAgent\` - Agent analysis
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`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.
These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`.

View file

@ -7,17 +7,16 @@ import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import slackSkill from "./slack/skill.js";
import workflowAuthoringSkill from "./workflow-authoring/skill.js";
import workflowRunOpsSkill from "./workflow-run-ops/skill.js";
const CURRENT_FILE = fileURLToPath(import.meta.url);
const CURRENT_DIR = path.dirname(CURRENT_FILE);
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
type SkillDefinition = {
id: string;
id: string; // Also used as folder name
title: string;
folder: string;
summary: string;
content: string;
};
@ -32,63 +31,60 @@ const definitions: SkillDefinition[] = [
{
id: "doc-collab",
title: "Document Collaboration",
folder: "doc-collab",
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
content: docCollabSkill,
},
{
id: "draft-emails",
title: "Draft Emails",
folder: "draft-emails",
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
content: draftEmailsSkill,
},
{
id: "meeting-prep",
title: "Meeting Prep",
folder: "meeting-prep",
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
content: meetingPrepSkill,
},
{
id: "organize-files",
title: "Organize Files",
folder: "organize-files",
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
content: organizeFilesSkill,
},
{
id: "slack",
title: "Slack Integration",
summary: "Send Slack messages, view channel history, search conversations, find users, and manage team communication.",
content: slackSkill,
},
{
id: "workflow-authoring",
title: "Workflow Authoring",
folder: "workflow-authoring",
summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.",
content: workflowAuthoringSkill,
},
{
id: "builtin-tools",
title: "Builtin Tools Reference",
folder: "builtin-tools",
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.",
content: builtinToolsSkill,
},
{
id: "mcp-integration",
title: "MCP Integration Guidance",
folder: "mcp-integration",
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
content: mcpIntegrationSkill,
},
{
id: "deletion-guardrails",
title: "Deletion Guardrails",
folder: "deletion-guardrails",
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
content: deletionGuardrailsSkill,
},
{
id: "workflow-run-ops",
title: "Workflow Run Operations",
folder: "workflow-run-ops",
summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.",
content: workflowRunOpsSkill,
},
@ -96,7 +92,7 @@ const definitions: SkillDefinition[] = [
const skillEntries = definitions.map((definition) => ({
...definition,
catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`,
catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`,
}));
const catalogSections = skillEntries.map((entry) => [
@ -146,8 +142,8 @@ const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
};
for (const entry of skillEntries) {
const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts");
const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js");
const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts");
const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js");
const resolvedEntry: ResolvedSkill = {
id: entry.id,
catalogPath: entry.catalogPath,
@ -156,14 +152,13 @@ for (const entry of skillEntries) {
const baseAliases = [
entry.id,
entry.folder,
`${entry.folder}/skill`,
`${entry.folder}/skill.ts`,
`${entry.folder}/skill.js`,
`skills/${entry.folder}/skill.ts`,
`skills/${entry.folder}/skill.js`,
`${CATALOG_PREFIX}/${entry.folder}/skill.ts`,
`${CATALOG_PREFIX}/${entry.folder}/skill.js`,
`${entry.id}/skill`,
`${entry.id}/skill.ts`,
`${entry.id}/skill.js`,
`skills/${entry.id}/skill.ts`,
`skills/${entry.id}/skill.js`,
`${CATALOG_PREFIX}/${entry.id}/skill.ts`,
`${CATALOG_PREFIX}/${entry.id}/skill.js`,
absoluteTs,
absoluteJs,
];

View file

@ -0,0 +1,121 @@
import { slackToolCatalogMarkdown } from "./tool-catalog.js";
const skill = String.raw`
# Slack Integration Skill
You can interact with Slack to help users communicate with their team. This includes sending messages, viewing channel history, finding users, and searching conversations.
## Prerequisites
Before using Slack tools, ALWAYS check if Slack is connected:
\`\`\`
slack-checkConnection({})
\`\`\`
If not connected, inform the user they need to connect Slack from the settings/onboarding.
## Available Tools
### Check Connection
\`\`\`
slack-checkConnection({})
\`\`\`
Returns whether Slack is connected and ready to use.
### List Users
\`\`\`
slack-listUsers({ limit: 100 })
\`\`\`
Lists users in the workspace. Use this to resolve a name to a user ID.
### List DM Conversations
\`\`\`
slack-getDirectMessages({ limit: 50 })
\`\`\`
Lists DM channels (type "im"). Each entry includes the DM channel ID and the user ID.
### List Channels
\`\`\`
slack-listChannels({ types: "public_channel,private_channel", limit: 100 })
\`\`\`
Lists channels the user has access to.
### Get Conversation History
\`\`\`
slack-getChannelHistory({ channel: "C01234567", limit: 20 })
\`\`\`
Fetches recent messages for a channel or DM.
### Search Messages
\`\`\`
slack-searchMessages({ query: "in:@username", count: 20 })
\`\`\`
Searches Slack messages using Slack search syntax.
### Send a Message
\`\`\`
slack-sendMessage({ channel: "C01234567", text: "Hello team!" })
\`\`\`
Sends a message to a channel or DM. Always show the draft first.
### Execute a Slack Action
\`\`\`
slack-executeAction({
toolSlug: "EXACT_TOOL_SLUG_FROM_DISCOVERY",
input: { /* tool-specific parameters */ }
})
\`\`\`
Executes any Slack tool using its exact slug discovered from \`slack-listAvailableTools\`.
### Discover Available Tools (Fallback)
\`\`\`
slack-listAvailableTools({ search: "conversation" })
\`\`\`
Lists available Slack tools from Composio. Use this only if a builtin Slack tool fails and you need a specific slug.
## Composio Slack Tool Catalog (Pinned)
Use the exact tool slugs below with \`slack-executeAction\` when needed. Prefer these over \`slack-listAvailableTools\` to avoid redundant discovery.
${slackToolCatalogMarkdown}
## Workflow
### Step 1: Check Connection
\`\`\`
slack-checkConnection({})
\`\`\`
### Step 2: Choose the Builtin Tool
Use the builtin Slack tools above for common tasks. Only fall back to \`slack-listAvailableTools\` + \`slack-executeAction\` if something is missing.
## Common Tasks
### Find the Most Recent DM with Someone
1. Search messages first: \`slack-searchMessages({ query: "in:@Name", count: 1 })\`
2. If you need exact DM history:
- \`slack-listUsers({})\` to find the user ID
- \`slack-getDirectMessages({})\` to find the DM channel for that user
- \`slack-getChannelHistory({ channel: "D...", limit: 20 })\`
### Send a Message
1. Draft the message and show it to the user
2. ONLY after user approval, send using \`slack-sendMessage\`
### Search Messages
1. Use \`slack-searchMessages({ query: "...", count: 20 })\`
## Best Practices
- **Always show drafts before sending** - Never send Slack messages without user confirmation
- **Summarize, don't dump** - When showing channel history, summarize the key points
- **Cross-reference with knowledge base** - Check if mentioned people have notes in the knowledge base
## Error Handling
If a Slack operation fails:
1. Try \`slack-listAvailableTools\` to verify the tool slug is correct
2. Check if Slack is still connected with \`slack-checkConnection\`
3. Inform the user of the specific error
`;
export default skill;

View file

@ -0,0 +1,117 @@
export type SlackToolDefinition = {
name: string;
slug: string;
description: string;
};
export const slackToolCatalog: SlackToolDefinition[] = [
{ name: "Add Emoji Alias", slug: "SLACK_ADD_AN_EMOJI_ALIAS_IN_SLACK", description: "Adds an alias for an existing custom emoji." },
{ name: "Add Remote File", slug: "SLACK_ADD_A_REMOTE_FILE_FROM_A_SERVICE", description: "Adds a reference to an external file (e.g., GDrive, Dropbox) to Slack." },
{ name: "Add Star to Item", slug: "SLACK_ADD_A_STAR_TO_AN_ITEM", description: "Stars a channel, file, comment, or message." },
{ name: "Add Call Participants", slug: "SLACK_ADD_CALL_PARTICIPANTS", description: "Registers new participants added to a Slack call." },
{ name: "Add Emoji", slug: "SLACK_ADD_EMOJI", description: "Adds a custom emoji to a workspace via a unique name and URL." },
{ name: "Add Reaction", slug: "SLACK_ADD_REACTION_TO_AN_ITEM", description: "Adds a specified emoji reaction to a message." },
{ name: "Archive Channel", slug: "SLACK_ARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Archives a public or private channel." },
{ name: "Archive Conversation", slug: "SLACK_ARCHIVE_A_SLACK_CONVERSATION", description: "Archives a conversation by its ID." },
{ name: "Close DM/MPDM", slug: "SLACK_CLOSE_DM_OR_MULTI_PERSON_DM", description: "Closes a DM or MPDM sidebar view for the user." },
{ name: "Create Reminder", slug: "SLACK_CREATE_A_REMINDER", description: "Creates a reminder with text and time (natural language supported)." },
{ name: "Create User Group", slug: "SLACK_CREATE_A_SLACK_USER_GROUP", description: "Creates a new user group (subteam)." },
{ name: "Create Channel", slug: "SLACK_CREATE_CHANNEL", description: "Initiates a public or private channel conversation." },
{ name: "Create Channel Conversation", slug: "SLACK_CREATE_CHANNEL_BASED_CONVERSATION", description: "Creates a new channel with specific org-wide or team settings." },
{ name: "Customize URL Unfurl", slug: "SLACK_CUSTOMIZE_URL_UNFURL", description: "Defines custom content for URL previews in a specific message." },
{ name: "Delete File Comment", slug: "SLACK_DELETE_A_COMMENT_ON_A_FILE", description: "Deletes a specific comment from a file." },
{ name: "Delete File", slug: "SLACK_DELETE_A_FILE_BY_ID", description: "Permanently deletes a file by its ID." },
{ name: "Delete Channel", slug: "SLACK_DELETE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Irreversibly deletes a channel and its history (Enterprise only)." },
{ name: "Delete Scheduled Message", slug: "SLACK_DELETE_A_SCHEDULED_MESSAGE_IN_A_CHAT", description: "Deletes a pending scheduled message." },
{ name: "Delete Reminder", slug: "SLACK_DELETE_A_SLACK_REMINDER", description: "Deletes an existing reminder." },
{ name: "Delete Message", slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", description: "Deletes a message by channel ID and timestamp." },
{ name: "Delete Profile Photo", slug: "SLACK_DELETE_USER_PROFILE_PHOTO", description: "Reverts the user's profile photo to the default avatar." },
{ name: "Disable User Group", slug: "SLACK_DISABLE_AN_EXISTING_SLACK_USER_GROUP", description: "Disables (archives) a user group." },
{ name: "Enable User Group", slug: "SLACK_ENABLE_A_SPECIFIED_USER_GROUP", description: "Reactivates a disabled user group." },
{ name: "Share File Publicly", slug: "SLACK_ENABLE_PUBLIC_SHARING_OF_A_FILE", description: "Generates a public URL for a file." },
{ name: "End Call", slug: "SLACK_END_A_CALL_WITH_DURATION_AND_ID", description: "Ends an ongoing call." },
{ name: "End Snooze", slug: "SLACK_END_SNOOZE", description: "Ends the current user's snooze mode immediately." },
{ name: "End DND Session", slug: "SLACK_END_USER_DO_NOT_DISTURB_SESSION", description: "Ends the current DND session." },
{ name: "Fetch Bot Info", slug: "SLACK_FETCH_BOT_USER_INFORMATION", description: "Fetches metadata for a specific bot user." },
{ name: "Fetch History", slug: "SLACK_FETCH_CONVERSATION_HISTORY", description: "Fetches chronological messages and events from a channel." },
{ name: "Fetch Item Reactions", slug: "SLACK_FETCH_ITEM_REACTIONS", description: "Fetches all reactions for a message, file, or comment." },
{ name: "Retrieve Replies", slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", description: "Retrieves replies to a specific parent message." },
{ name: "Fetch Team Info", slug: "SLACK_FETCH_TEAM_INFO", description: "Fetches comprehensive metadata about the team." },
{ name: "Fetch Workspace Settings", slug: "SLACK_FETCH_WORKSPACE_SETTINGS_INFORMATION", description: "Retrieves detailed settings for a specific workspace." },
{ name: "Find Channels", slug: "SLACK_FIND_CHANNELS", description: "Searches channels by name, topic, or purpose." },
{ name: "Find User by Email", slug: "SLACK_FIND_USER_BY_EMAIL_ADDRESS", description: "Finds a user object using their email address." },
{ name: "Find Users", slug: "SLACK_FIND_USERS", description: "Searches users by name, email, or display name." },
{ name: "Get Conversation Preferences", slug: "SLACK_GET_CHANNEL_CONVERSATION_PREFERENCES", description: "Retrieves posting/threading preferences for a channel." },
{ name: "Get Reminder Info", slug: "SLACK_GET_REMINDER_INFORMATION", description: "Retrieves detailed information for a specific reminder." },
{ name: "Get Remote File", slug: "SLACK_GET_REMOTE_FILE", description: "Retrieves info about a previously added remote file." },
{ name: "Get Team DND Status", slug: "SLACK_GET_TEAM_DND_STATUS", description: "Retrieves the DND status for specific users." },
{ name: "Get User Presence", slug: "SLACK_GET_USER_PRESENCE_INFO", description: "Retrieves real-time presence (active/away)." },
{ name: "Invite to Channel", slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", description: "Invites users to a channel by their user IDs." },
{ name: "Invite to Workspace", slug: "SLACK_INVITE_USER_TO_WORKSPACE", description: "Invites a user to a workspace and channels via email." },
{ name: "Join Conversation", slug: "SLACK_JOIN_AN_EXISTING_CONVERSATION", description: "Joins a conversation by channel ID." },
{ name: "Leave Conversation", slug: "SLACK_LEAVE_A_CONVERSATION", description: "Leaves a conversation." },
{ name: "List All Channels", slug: "SLACK_LIST_ALL_CHANNELS", description: "Lists all conversations with various filters." },
{ name: "List All Users", slug: "SLACK_LIST_ALL_USERS", description: "Retrieves a paginated list of all users in the workspace." },
{ name: "List User Group Members", slug: "SLACK_LIST_ALL_USERS_IN_A_USER_GROUP", description: "Lists all user IDs within a group." },
{ name: "List Conversations", slug: "SLACK_LIST_CONVERSATIONS", description: "Retrieves conversations accessible to a specific user." },
{ name: "List Files", slug: "SLACK_LIST_FILES_WITH_FILTERS_IN_SLACK", description: "Lists files and metadata with filtering options." },
{ name: "List Reminders", slug: "SLACK_LIST_REMINDERS", description: "Lists all reminders for the authenticated user." },
{ name: "List Remote Files", slug: "SLACK_LIST_REMOTE_FILES", description: "Retrieves info about a team's remote files." },
{ name: "List Scheduled Messages", slug: "SLACK_LIST_SCHEDULED_MESSAGES", description: "Lists pending scheduled messages." },
{ name: "List Pinned Items", slug: "SLACK_LISTS_PINNED_ITEMS_IN_A_CHANNEL", description: "Retrieves all messages/files pinned to a channel." },
{ name: "List Starred Items", slug: "SLACK_LIST_STARRED_ITEMS", description: "Lists items starred by the user." },
{ name: "List Custom Emojis", slug: "SLACK_LIST_TEAM_CUSTOM_EMOJIS", description: "Lists all workspace custom emojis and their URLs." },
{ name: "List User Groups", slug: "SLACK_LIST_USER_GROUPS_FOR_TEAM_WITH_OPTIONS", description: "Lists user-created and default user groups." },
{ name: "List User Reactions", slug: "SLACK_LIST_USER_REACTIONS", description: "Lists all reactions added by a specific user." },
{ name: "List Admin Users", slug: "SLACK_LIST_WORKSPACE_USERS", description: "Retrieves a paginated list of workspace administrators." },
{ name: "Set User Presence", slug: "SLACK_MANUALLY_SET_USER_PRESENCE", description: "Manually overrides automated presence status." },
{ name: "Mark Reminder Complete", slug: "SLACK_MARK_REMINDER_AS_COMPLETE", description: "Marks a reminder as complete (deprecated by Slack in March 2023)." },
{ name: "Open DM", slug: "SLACK_OPEN_DM", description: "Opens/resumes a DM or MPDM." },
{ name: "Pin Item", slug: "SLACK_PINS_AN_ITEM_TO_A_CHANNEL", description: "Pins a message to a channel." },
{ name: "Remove Remote File", slug: "SLACK_REMOVE_A_REMOTE_FILE", description: "Removes a reference to an external file." },
{ name: "Remove Star", slug: "SLACK_REMOVE_A_STAR_FROM_AN_ITEM", description: "Unstars an item." },
{ name: "Remove from Channel", slug: "SLACK_REMOVE_A_USER_FROM_A_CONVERSATION", description: "Removes a specified user from a conversation." },
{ name: "Remove Call Participants", slug: "SLACK_REMOVE_CALL_PARTICIPANTS", description: "Registers the removal of participants from a call." },
{ name: "Remove Reaction", slug: "SLACK_REMOVE_REACTION_FROM_ITEM", description: "Removes an emoji reaction from an item." },
{ name: "Rename Conversation", slug: "SLACK_RENAME_A_CONVERSATION", description: "Renames a channel ID/Conversation." },
{ name: "Rename Emoji", slug: "SLACK_RENAME_AN_EMOJI", description: "Renames a custom emoji." },
{ name: "Rename Channel", slug: "SLACK_RENAME_A_SLACK_CHANNEL", description: "Renames a public or private channel." },
{ name: "Retrieve Identity", slug: "SLACK_RETRIEVE_A_USER_S_IDENTITY_DETAILS", description: "Retrieves basic user/team identity details." },
{ name: "Retrieve Call Info", slug: "SLACK_RETRIEVE_CALL_INFORMATION", description: "Retrieves a snapshot of a call's status." },
{ name: "Retrieve Conversation Info", slug: "SLACK_RETRIEVE_CONVERSATION_INFORMATION", description: "Retrieves metadata for a specific conversation." },
{ name: "Get Conversation Members", slug: "SLACK_RETRIEVE_CONVERSATION_MEMBERS_LIST", description: "Lists active user IDs in a conversation." },
{ name: "Retrieve User DND", slug: "SLACK_RETRIEVE_CURRENT_USER_DND_STATUS", description: "Retrieves DND status for a user." },
{ name: "Retrieve File Details", slug: "SLACK_RETRIEVE_DETAILED_INFORMATION_ABOUT_A_FILE", description: "Retrieves metadata and comments for a file." },
{ name: "Retrieve User Details", slug: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", description: "Retrieves comprehensive info for a specific user ID." },
{ name: "Get Message Permalink", slug: "SLACK_RETRIEVE_MESSAGE_PERMALINK_URL", description: "Gets the permalink URL for a specific message." },
{ name: "Retrieve Team Profile", slug: "SLACK_RETRIEVE_TEAM_PROFILE_DETAILS", description: "Retrieves the profile field structure for a team." },
{ name: "Retrieve User Profile", slug: "SLACK_RETRIEVE_USER_PROFILE_INFORMATION", description: "Retrieves specific profile info for a user." },
{ name: "Revoke Public File", slug: "SLACK_REVOKE_PUBLIC_SHARING_ACCESS_FOR_A_FILE", description: "Revokes a file's public sharing URL." },
{ name: "Schedule Message", slug: "SLACK_SCHEDULE_MESSAGE", description: "Schedules a message for a future time (up to 120 days)." },
{ name: "Search Messages", slug: "SLACK_SEARCH_MESSAGES", description: "Workspace-wide message search with advanced filters." },
{ name: "Send Ephemeral", slug: "SLACK_SEND_EPHEMERAL_MESSAGE", description: "Sends a message visible only to a specific user." },
{ name: "Send Message", slug: "SLACK_SEND_MESSAGE", description: "Posts a message to a channel, DM, or group." },
{ name: "Set Conversation Purpose", slug: "SLACK_SET_A_CONVERSATION_S_PURPOSE", description: "Updates the purpose description of a channel." },
{ name: "Set DND Duration", slug: "SLACK_SET_DND_DURATION", description: "Turns on DND or changes its current duration." },
{ name: "Set Profile Photo", slug: "SLACK_SET_PROFILE_PHOTO", description: "Sets the user's profile image with cropping." },
{ name: "Set Read Cursor", slug: "SLACK_SET_READ_CURSOR_IN_A_CONVERSATION", description: "Marks a specific timestamp as read." },
{ name: "Set User Profile", slug: "SLACK_SET_SLACK_USER_PROFILE_INFORMATION", description: "Updates individual or multiple user profile fields." },
{ name: "Set Conversation Topic", slug: "SLACK_SET_THE_TOPIC_OF_A_CONVERSATION", description: "Updates the topic of a conversation." },
{ name: "Share Me Message", slug: "SLACK_SHARE_A_ME_MESSAGE_IN_A_CHANNEL", description: "Sends a third-person user action message (/me)." },
{ name: "Share Remote File", slug: "SLACK_SHARE_REMOTE_FILE_IN_CHANNELS", description: "Shares a registered remote file into channels." },
{ name: "Start Call", slug: "SLACK_START_CALL", description: "Registers a new call for third-party integration." },
{ name: "Start RTM Session", slug: "SLACK_START_REAL_TIME_MESSAGING_SESSION", description: "Initiates a real-time messaging WebSocket session." },
{ name: "Unarchive Channel", slug: "SLACK_UNARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Unarchives a specific channel." },
{ name: "Unarchive Conversation", slug: "SLACK_UNARCHIVE_CHANNEL", description: "Reverses archival for a conversation." },
{ name: "Unpin Item", slug: "SLACK_UNPIN_ITEM_FROM_CHANNEL", description: "Unpins a message from a channel." },
{ name: "Update User Group", slug: "SLACK_UPDATE_AN_EXISTING_SLACK_USER_GROUP", description: "Updates name, handle, or channels for a user group." },
{ name: "Update Remote File", slug: "SLACK_UPDATES_AN_EXISTING_REMOTE_FILE", description: "Updates metadata for a remote file reference." },
{ name: "Update Message", slug: "SLACK_UPDATES_A_SLACK_MESSAGE", description: "Modifies the content of an existing message." },
{ name: "Update Call Info", slug: "SLACK_UPDATE_SLACK_CALL_INFORMATION", description: "Updates call title or join URLs." },
{ name: "Update Group Members", slug: "SLACK_UPDATE_USER_GROUP_MEMBERS", description: "Replaces the member list of a user group." },
{ name: "Upload File", slug: "SLACK_UPLOAD_OR_CREATE_A_FILE_IN_SLACK", description: "Uploads content or binary files to Slack." },
];
export const slackToolCatalogMarkdown = slackToolCatalog
.map((tool) => `- ${tool.name} (${tool.slug}) - ${tool.description}`)
.join("\n");

View file

@ -11,6 +11,9 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js";
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 { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js";
import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const BuiltinToolsSchema = z.record(z.string(), z.object({
@ -22,6 +25,232 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({
}),
}));
type SlackToolHint = {
search?: string;
patterns: string[];
fallbackSlugs?: string[];
preferSlugIncludes?: string[];
excludePatterns?: string[];
minScore?: number;
};
const slackToolHints: Record<string, SlackToolHint> = {
sendMessage: {
search: "message",
patterns: ["send", "message", "channel"],
fallbackSlugs: [
"SLACK_SEND_MESSAGE",
"SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL",
"SLACK_SEND_A_MESSAGE",
],
},
listConversations: {
search: "conversation",
patterns: ["list", "conversation", "channel"],
fallbackSlugs: [
"SLACK_LIST_CONVERSATIONS",
"SLACK_LIST_ALL_CHANNELS",
"SLACK_LIST_ALL_SLACK_TEAM_CHANNELS_WITH_VARIOUS_FILTERS",
"SLACK_LIST_CHANNELS",
"SLACK_LIST_CHANNEL",
],
preferSlugIncludes: ["list", "conversation"],
minScore: 2,
},
getConversationHistory: {
search: "history",
patterns: ["history", "conversation", "message"],
fallbackSlugs: [
"SLACK_FETCH_CONVERSATION_HISTORY",
"SLACK_FETCHES_CONVERSATION_HISTORY",
"SLACK_GET_CONVERSATION_HISTORY",
"SLACK_GET_CHANNEL_HISTORY",
],
preferSlugIncludes: ["history"],
minScore: 2,
},
listUsers: {
search: "user",
patterns: ["list", "user"],
fallbackSlugs: [
"SLACK_LIST_ALL_USERS",
"SLACK_LIST_ALL_SLACK_TEAM_USERS_WITH_PAGINATION",
"SLACK_LIST_USERS",
"SLACK_GET_USERS",
"SLACK_USERS_LIST",
],
preferSlugIncludes: ["list", "user"],
excludePatterns: ["find", "by name", "by email", "by_email", "by_name", "lookup", "profile", "info"],
minScore: 2,
},
getUserInfo: {
search: "user",
patterns: ["user", "info", "profile"],
fallbackSlugs: [
"SLACK_GET_USER_INFO",
"SLACK_GET_USER",
"SLACK_USER_INFO",
],
preferSlugIncludes: ["user", "info"],
minScore: 1,
},
searchMessages: {
search: "search",
patterns: ["search", "message"],
fallbackSlugs: [
"SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY",
"SLACK_SEARCH_MESSAGES",
"SLACK_SEARCH_MESSAGE",
],
preferSlugIncludes: ["search"],
minScore: 1,
},
};
const slackToolSlugCache = new Map<string, string>();
const slackToolSlugOverrides: Partial<Record<keyof typeof slackToolHints, string>> = {
sendMessage: "SLACK_SEND_MESSAGE",
listConversations: "SLACK_LIST_CONVERSATIONS",
getConversationHistory: "SLACK_FETCH_CONVERSATION_HISTORY",
listUsers: "SLACK_LIST_ALL_USERS",
getUserInfo: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION",
searchMessages: "SLACK_SEARCH_MESSAGES",
};
const compactObject = (input: Record<string, unknown>) =>
Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
type SlackToolResult = { success: boolean; data?: unknown; error?: string };
/** Helper to execute a Slack tool with consistent account validation and error handling */
async function executeSlackTool(
hintKey: keyof typeof slackToolHints,
params: Record<string, unknown>
): Promise<SlackToolResult> {
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return { success: false, error: 'Slack is not connected' };
}
try {
const toolSlug = await resolveSlackToolSlug(hintKey);
return await executeComposioAction(toolSlug, account.id, compactObject(params));
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
const normalizeSlackTool = (tool: { slug: string; name?: string; description?: string }) =>
`${tool.slug} ${tool.name || ""} ${tool.description || ""}`.toLowerCase();
const scoreSlackTool = (tool: { slug: string; name?: string; description?: string }, patterns: string[]) => {
const slug = tool.slug.toLowerCase();
const name = (tool.name || "").toLowerCase();
const description = (tool.description || "").toLowerCase();
let score = 0;
for (const pattern of patterns) {
const needle = pattern.toLowerCase();
if (slug.includes(needle)) score += 3;
if (name.includes(needle)) score += 2;
if (description.includes(needle)) score += 1;
}
return score;
};
const pickSlackTool = (
tools: Array<{ slug: string; name?: string; description?: string }>,
hint: SlackToolHint,
) => {
let candidates = tools;
if (hint.excludePatterns && hint.excludePatterns.length > 0) {
candidates = candidates.filter((tool) => {
const haystack = normalizeSlackTool(tool);
return !hint.excludePatterns!.some((pattern) => haystack.includes(pattern.toLowerCase()));
});
}
if (hint.preferSlugIncludes && hint.preferSlugIncludes.length > 0) {
const preferred = candidates.filter((tool) =>
hint.preferSlugIncludes!.every((pattern) => tool.slug.toLowerCase().includes(pattern.toLowerCase()))
);
if (preferred.length > 0) {
candidates = preferred;
}
}
let best: { slug: string; name?: string; description?: string } | null = null;
let bestScore = 0;
for (const tool of candidates) {
const score = scoreSlackTool(tool, hint.patterns);
if (score > bestScore) {
bestScore = score;
best = tool;
}
}
if (!best || (hint.minScore !== undefined && bestScore < hint.minScore)) {
return null;
}
return best;
};
const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => {
const cached = slackToolSlugCache.get(hintKey);
if (cached) return cached;
const hint = slackToolHints[hintKey];
const override = slackToolSlugOverrides[hintKey];
if (override && slackToolCatalog.some((tool) => tool.slug === override)) {
slackToolSlugCache.set(hintKey, override);
return override;
}
const resolveFromTools = (tools: Array<{ slug: string; name?: string; description?: string }>) => {
if (hint.fallbackSlugs && hint.fallbackSlugs.length > 0) {
const fallbackSet = new Set(hint.fallbackSlugs.map((slug) => slug.toLowerCase()));
const fallback = tools.find((tool) => fallbackSet.has(tool.slug.toLowerCase()));
if (fallback) return fallback.slug;
}
const best = pickSlackTool(tools, hint);
return best?.slug || null;
};
const initialTools = slackToolCatalog;
if (!initialTools.length) {
throw new Error("No Slack tools returned from Composio");
}
const initialSlug = resolveFromTools(initialTools);
if (initialSlug) {
slackToolSlugCache.set(hintKey, initialSlug);
return initialSlug;
}
const allSlug = resolveFromTools(slackToolCatalog);
if (!allSlug) {
const fallback = await listToolkitTools("slack", hint.search || null);
const fallbackSlug = resolveFromTools(fallback.items || []);
if (!fallbackSlug) {
throw new Error(`Unable to resolve Slack tool for ${hintKey}. Try slack-listAvailableTools.`);
}
slackToolSlugCache.set(hintKey, fallbackSlug);
return fallbackSlug;
}
slackToolSlugCache.set(hintKey, allSlug);
return allSlug;
};
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
@ -630,7 +859,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// }
const result = await executeCommand(command, { cwd: workingDir });
return {
success: result.exitCode === 0,
stdout: result.stdout,
@ -648,4 +877,162 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
},
},
// ============================================================================
// Slack Tools (via Composio)
// ============================================================================
'slack-checkConnection': {
description: 'Check if Slack is connected and ready to use. Use this before other Slack operations.',
inputSchema: z.object({}),
execute: async () => {
if (!isComposioConfigured()) {
return {
connected: false,
error: 'Composio is not configured. Please set up your Composio API key first.',
};
}
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return {
connected: false,
error: 'Slack is not connected. Please connect Slack from the settings.',
};
}
return {
connected: true,
accountId: account.id,
};
},
},
'slack-listAvailableTools': {
description: 'List available Slack tools from Composio. Use this to discover the correct tool slugs before executing actions. Call this first if other Slack tools return errors.',
inputSchema: z.object({
search: z.string().optional().describe('Optional search query to filter tools (e.g., "message", "channel", "user")'),
}),
execute: async ({ search }: { search?: string }) => {
if (!isComposioConfigured()) {
return { success: false, error: 'Composio is not configured' };
}
try {
const result = await listToolkitTools('slack', search || null);
return {
success: true,
tools: result.items,
count: result.items.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
'slack-executeAction': {
description: 'Execute a Slack action by its Composio tool slug. Use slack-listAvailableTools first to discover correct slugs. Pass the exact slug and the required input parameters.',
inputSchema: z.object({
toolSlug: z.string().describe('The exact Composio tool slug (e.g., "SLACKBOT_SEND_A_MESSAGE_TO_A_SLACK_CHANNEL")'),
input: z.record(z.string(), z.unknown()).describe('Input parameters for the tool (check the tool description for required fields)'),
}),
execute: async ({ toolSlug, input }: { toolSlug: string; input: Record<string, unknown> }) => {
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return { success: false, error: 'Slack is not connected' };
}
try {
const result = await executeComposioAction(toolSlug, account.id, input);
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
'slack-sendMessage': {
description: 'Send a message to a Slack channel or user. Requires channel ID (starts with C for channels, D for DMs) or user ID.',
inputSchema: z.object({
channel: z.string().describe('Channel ID (e.g., C01234567) or user ID (e.g., U01234567) to send the message to'),
text: z.string().describe('The message text to send'),
}),
execute: async ({ channel, text }: { channel: string; text: string }) => {
return executeSlackTool("sendMessage", { channel, text });
},
},
'slack-listChannels': {
description: 'List Slack channels the user has access to. Returns channel IDs and names.',
inputSchema: z.object({
types: z.string().optional().describe('Comma-separated channel types: public_channel, private_channel, mpim, im (default: public_channel,private_channel)'),
limit: z.number().optional().describe('Maximum number of channels to return (default: 100)'),
}),
execute: async ({ types, limit }: { types?: string; limit?: number }) => {
return executeSlackTool("listConversations", {
types: types || "public_channel,private_channel",
limit: limit ?? 100,
});
},
},
'slack-getChannelHistory': {
description: 'Get recent messages from a Slack channel. Returns message history with timestamps and user IDs.',
inputSchema: z.object({
channel: z.string().describe('Channel ID to get history from (e.g., C01234567)'),
limit: z.number().optional().describe('Maximum number of messages to return (default: 20, max: 100)'),
}),
execute: async ({ channel, limit }: { channel: string; limit?: number }) => {
return executeSlackTool("getConversationHistory", {
channel,
limit: limit !== undefined ? Math.min(limit, 100) : 20,
});
},
},
'slack-listUsers': {
description: 'List users in the Slack workspace. Returns user IDs, names, and profile info.',
inputSchema: z.object({
limit: z.number().optional().describe('Maximum number of users to return (default: 100)'),
}),
execute: async ({ limit }: { limit?: number }) => {
return executeSlackTool("listUsers", { limit: limit ?? 100 });
},
},
'slack-getUserInfo': {
description: 'Get detailed information about a specific Slack user by their user ID.',
inputSchema: z.object({
user: z.string().describe('User ID to get info for (e.g., U01234567)'),
}),
execute: async ({ user }: { user: string }) => {
return executeSlackTool("getUserInfo", { user });
},
},
'slack-searchMessages': {
description: 'Search for messages in Slack. Find messages containing specific text across channels.',
inputSchema: z.object({
query: z.string().describe('Search query text'),
count: z.number().optional().describe('Maximum number of results (default: 20)'),
}),
execute: async ({ query, count }: { query: string; count?: number }) => {
return executeSlackTool("searchMessages", { query, count: count ?? 20 });
},
},
'slack-getDirectMessages': {
description: 'List direct message (DM) channels. Returns IDs of DM conversations with other users.',
inputSchema: z.object({
limit: z.number().optional().describe('Maximum number of DM channels to return (default: 50)'),
}),
execute: async ({ limit }: { limit?: number }) => {
return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 });
},
},
};

View file

@ -0,0 +1,358 @@
import { z } from "zod";
import fs from "fs";
import path from "path";
import { Composio } from "@composio/core";
import { WorkDir } from "../config/config.js";
import {
ZAuthConfig,
ZConnectedAccount,
ZCreateAuthConfigRequest,
ZCreateAuthConfigResponse,
ZCreateConnectedAccountRequest,
ZCreateConnectedAccountResponse,
ZDeleteOperationResponse,
ZErrorResponse,
ZExecuteActionResponse,
ZListResponse,
ZToolkit,
} from "./types.js";
const BASE_URL = 'https://backend.composio.dev/api/v3';
const CONFIG_FILE = path.join(WorkDir, 'config', 'composio.json');
// Composio SDK client (lazily initialized)
let composioClient: Composio | null = null;
function getComposioClient(): Composio {
if (composioClient) {
return composioClient;
}
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
composioClient = new Composio({ apiKey });
return composioClient;
}
function resetComposioClient(): void {
composioClient = null;
}
/**
* Configuration schema for Composio
*/
const ZComposioConfig = z.object({
apiKey: z.string().optional(),
});
type ComposioConfig = z.infer<typeof ZComposioConfig>;
/**
* Load Composio configuration
*/
function loadConfig(): ComposioConfig {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
return ZComposioConfig.parse(JSON.parse(data));
}
} catch (error) {
console.error('[Composio] Failed to load config:', error);
}
return {};
}
/**
* Save Composio configuration
*/
export function saveConfig(config: ComposioConfig): void {
const dir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
/**
* Get the Composio API key
*/
export function getApiKey(): string | null {
const config = loadConfig();
return config.apiKey || process.env.COMPOSIO_API_KEY || null;
}
/**
* Set the Composio API key
*/
export function setApiKey(apiKey: string): void {
const config = loadConfig();
config.apiKey = apiKey;
saveConfig(config);
resetComposioClient();
}
/**
* Check if Composio is configured
*/
export function isConfigured(): boolean {
return !!getApiKey();
}
/**
* Make an API call to Composio
*/
export async function composioApiCall<T extends z.ZodTypeAny>(
schema: T,
url: string,
options: RequestInit = {},
): Promise<z.infer<T>> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
console.log(`[Composio] ${options.method || 'GET'} ${url}`);
const startTime = Date.now();
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
"x-api-key": apiKey,
...(options.method === 'POST' ? { "Content-Type": "application/json" } : {}),
},
});
const duration = Date.now() - startTime;
console.log(`[Composio] Response in ${duration}ms`);
const contentType = response.headers.get('content-type') || '';
const rawText = await response.text();
if (!response.ok || !contentType.includes('application/json')) {
console.error(`[Composio] Error response:`, {
status: response.status,
statusText: response.statusText,
contentType,
preview: rawText.slice(0, 200),
});
}
if (!response.ok) {
throw new Error(`Composio API error: ${response.status} ${response.statusText}`);
}
if (!contentType.includes('application/json')) {
throw new Error('Expected JSON response');
}
let data: unknown;
try {
data = JSON.parse(rawText);
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error';
throw new Error(`Failed to parse response: ${message}`);
}
if (typeof data === 'object' && data !== null && 'error' in data) {
const parsedError = ZErrorResponse.parse(data);
throw new Error(`Composio error (${parsedError.error.error_code}): ${parsedError.error.message}`);
}
return schema.parse(data);
} catch (error) {
console.error(`[Composio] Error:`, error);
throw error;
}
}
/**
* List available toolkits
*/
export async function listToolkits(cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
const url = new URL(`${BASE_URL}/toolkits`);
url.searchParams.set("sort_by", "usage");
if (cursor) {
url.searchParams.set("cursor", cursor);
}
return composioApiCall(ZListResponse(ZToolkit), url.toString());
}
/**
* Get a specific toolkit
*/
export async function getToolkit(toolkitSlug: string): Promise<z.infer<typeof ZToolkit>> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
const url = `${BASE_URL}/toolkits/${toolkitSlug}`;
console.log(`[Composio] GET ${url}`);
const response = await fetch(url, {
headers: { "x-api-key": apiKey },
});
if (!response.ok) {
throw new Error(`Failed to fetch toolkit: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const no_auth = data.composio_managed_auth_schemes?.includes('NO_AUTH') ||
data.auth_config_details?.some((config: { mode: string }) => config.mode === 'NO_AUTH') ||
false;
return ZToolkit.parse({
...data,
no_auth,
meta: data.meta || { description: '', logo: '', tools_count: 0, triggers_count: 0 },
auth_schemes: data.auth_schemes || [],
composio_managed_auth_schemes: data.composio_managed_auth_schemes || [],
});
}
/**
* List auth configs for a toolkit
*/
export async function listAuthConfigs(
toolkitSlug: string,
cursor: string | null = null,
managedOnly: boolean = false
): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZAuthConfig>>>> {
const url = new URL(`${BASE_URL}/auth_configs`);
url.searchParams.set("toolkit_slug", toolkitSlug);
if (cursor) {
url.searchParams.set("cursor", cursor);
}
if (managedOnly) {
url.searchParams.set("is_composio_managed", "true");
}
return composioApiCall(ZListResponse(ZAuthConfig), url.toString());
}
/**
* Create an auth config
*/
export async function createAuthConfig(
request: z.infer<typeof ZCreateAuthConfigRequest>
): Promise<z.infer<typeof ZCreateAuthConfigResponse>> {
const url = new URL(`${BASE_URL}/auth_configs`);
return composioApiCall(ZCreateAuthConfigResponse, url.toString(), {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Delete an auth config
*/
export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`);
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
method: 'DELETE',
});
}
/**
* Create a connected account
*/
export async function createConnectedAccount(
request: z.infer<typeof ZCreateConnectedAccountRequest>
): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
const url = new URL(`${BASE_URL}/connected_accounts`);
return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Get a connected account
*/
export async function getConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZConnectedAccount>> {
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
return composioApiCall(ZConnectedAccount, url.toString());
}
/**
* Delete a connected account
*/
export async function deleteConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
method: 'DELETE',
});
}
/**
* List available tools for a toolkit
*/
export async function listToolkitTools(
toolkitSlug: string,
searchQuery: string | null = null,
): Promise<{ items: Array<{ slug: string; name: string; description: 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 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) => ({
slug: String(item.slug ?? ''),
name: String(item.name ?? ''),
description: String(item.description ?? ''),
})),
};
}
/**
* Execute a tool action using Composio SDK
*/
export async function executeAction(
actionSlug: string,
connectedAccountId: string,
input: Record<string, unknown>
): Promise<z.infer<typeof ZExecuteActionResponse>> {
console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`);
try {
const client = getComposioClient();
const result = await client.tools.execute(actionSlug, {
userId: connectedAccountId,
arguments: input,
connectedAccountId,
});
console.log(`[Composio] Action completed successfully`);
return { success: true, data: result.data };
} catch (error) {
console.error(`[Composio] Action execution failed:`, error);
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, data: null, error: message };
}
}

View file

@ -0,0 +1,5 @@
// Composio integration for Rowboat X
export * from './types.js';
export * from './client.js';
export * from './repo.js';

View file

@ -0,0 +1,140 @@
import fs from "fs";
import path from "path";
import { z } from "zod";
import { WorkDir } from "../config/config.js";
import { ZLocalConnectedAccount, LocalConnectedAccount, ConnectedAccountStatus } from "./types.js";
const ACCOUNTS_FILE = path.join(WorkDir, 'data', 'composio', 'connected_accounts.json');
/**
* Schema for the connected accounts storage file
*/
const ZConnectedAccountsStorage = z.object({
accounts: z.record(z.string(), ZLocalConnectedAccount), // keyed by toolkit slug
});
type ConnectedAccountsStorage = z.infer<typeof ZConnectedAccountsStorage>;
/**
* Interface for Composio accounts repository
*/
export interface IComposioAccountsRepo {
getAccount(toolkitSlug: string): LocalConnectedAccount | null;
getAllAccounts(): Record<string, LocalConnectedAccount>;
saveAccount(account: LocalConnectedAccount): void;
updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean;
deleteAccount(toolkitSlug: string): void;
isConnected(toolkitSlug: string): boolean;
getConnectedToolkits(): string[];
}
/**
* Ensure the storage directory exists
*/
function ensureStorageDir(): void {
const dir = path.dirname(ACCOUNTS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Load connected accounts from storage
*/
function loadAccounts(): ConnectedAccountsStorage {
try {
if (fs.existsSync(ACCOUNTS_FILE)) {
const data = fs.readFileSync(ACCOUNTS_FILE, 'utf-8');
return ZConnectedAccountsStorage.parse(JSON.parse(data));
}
} catch (error) {
console.error('[ComposioRepo] Failed to load accounts:', error);
}
return { accounts: {} };
}
/**
* Save connected accounts to storage
*/
function saveAccounts(storage: ConnectedAccountsStorage): void {
ensureStorageDir();
fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(storage, null, 2));
}
/**
* Composio Connected Accounts Repository
* Stores connected account information locally
*/
export class ComposioAccountsRepo implements IComposioAccountsRepo {
/**
* Get a connected account by toolkit slug
*/
getAccount(toolkitSlug: string): LocalConnectedAccount | null {
const storage = loadAccounts();
return storage.accounts[toolkitSlug] || null;
}
/**
* Get all connected accounts
*/
getAllAccounts(): Record<string, LocalConnectedAccount> {
const storage = loadAccounts();
return storage.accounts;
}
/**
* Save a connected account
*/
saveAccount(account: LocalConnectedAccount): void {
const storage = loadAccounts();
storage.accounts[account.toolkitSlug] = account;
saveAccounts(storage);
}
/**
* Update account status
* @returns true if account was found and updated, false if account doesn't exist
*/
updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean {
const storage = loadAccounts();
const account = storage.accounts[toolkitSlug];
if (!account) {
console.warn(`[ComposioRepo] Cannot update status: account '${toolkitSlug}' not found`);
return false;
}
account.status = status;
account.lastUpdatedAt = new Date().toISOString();
saveAccounts(storage);
return true;
}
/**
* Delete a connected account
*/
deleteAccount(toolkitSlug: string): void {
const storage = loadAccounts();
delete storage.accounts[toolkitSlug];
saveAccounts(storage);
}
/**
* Check if a toolkit is connected
*/
isConnected(toolkitSlug: string): boolean {
const account = this.getAccount(toolkitSlug);
return account?.status === 'ACTIVE';
}
/**
* Get list of connected toolkit slugs
*/
getConnectedToolkits(): string[] {
const storage = loadAccounts();
return Object.entries(storage.accounts)
.filter(([, account]) => account.status === 'ACTIVE')
.map(([slug]) => slug);
}
}
// Export singleton instance
export const composioAccountsRepo = new ComposioAccountsRepo();

View file

@ -0,0 +1,237 @@
import { z } from "zod";
/**
* Composio authentication schemes
*/
export const ZAuthScheme = z.enum([
'API_KEY',
'BASIC',
'BASIC_WITH_JWT',
'BEARER_TOKEN',
'COMPOSIO_LINK',
'SERVICE_ACCOUNT',
'GOOGLE_SERVICE_ACCOUNT',
'NO_AUTH',
'OAUTH1',
'OAUTH2',
]);
/**
* Connected account status
*/
export const ZConnectedAccountStatus = z.enum([
'INITIALIZING',
'INITIATED',
'ACTIVE',
'FAILED',
'EXPIRED',
'INACTIVE',
]);
/**
* Toolkit metadata
*/
export const ZToolkitMeta = z.object({
description: z.string(),
logo: z.string(),
tools_count: z.number(),
triggers_count: z.number(),
});
/**
* Toolkit schema
*/
export const ZToolkit = z.object({
slug: z.string(),
name: z.string(),
meta: ZToolkitMeta,
no_auth: z.boolean(),
auth_schemes: z.array(ZAuthScheme),
composio_managed_auth_schemes: z.array(ZAuthScheme),
});
/**
* Tool schema
*/
export const ZTool = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkit: z.object({
slug: z.string(),
name: z.string(),
logo: z.string(),
}),
input_parameters: z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.unknown()),
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
}),
no_auth: z.boolean(),
});
/**
* Auth config schema
*/
export const ZAuthConfig = z.object({
id: z.string(),
is_composio_managed: z.boolean(),
auth_scheme: ZAuthScheme,
});
/**
* Credentials schema
*/
export const ZCredentials = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
/**
* Create auth config request
*/
export const ZCreateAuthConfigRequest = z.object({
toolkit: z.object({
slug: z.string(),
}),
auth_config: z.discriminatedUnion('type', [
z.object({
type: z.literal('use_composio_managed_auth'),
name: z.string().optional(),
credentials: ZCredentials.optional(),
}),
z.object({
type: z.literal('use_custom_auth'),
authScheme: ZAuthScheme,
credentials: ZCredentials,
name: z.string().optional(),
}),
]).optional(),
});
/**
* Create auth config response
*/
export const ZCreateAuthConfigResponse = z.object({
toolkit: z.object({
slug: z.string(),
}),
auth_config: ZAuthConfig,
});
/**
* Connection data schema
*/
export const ZConnectionData = z.object({
authScheme: ZAuthScheme,
val: z.record(z.string(), z.unknown())
.and(z.object({
status: ZConnectedAccountStatus,
})),
});
/**
* Create connected account request
*/
export const ZCreateConnectedAccountRequest = z.object({
auth_config: z.object({
id: z.string(),
}),
connection: z.object({
state: ZConnectionData.optional(),
user_id: z.string().optional(),
callback_url: z.string().optional(),
}),
});
/**
* Create connected account response
*/
export const ZCreateConnectedAccountResponse = z.object({
id: z.string(),
connectionData: ZConnectionData,
});
/**
* Connected account schema
*/
export const ZConnectedAccount = z.object({
id: z.string(),
toolkit: z.object({
slug: z.string(),
}),
auth_config: z.object({
id: z.string(),
is_composio_managed: z.boolean(),
is_disabled: z.boolean(),
}),
status: ZConnectedAccountStatus,
});
/**
* Error response schema
*/
export const ZErrorResponse = z.object({
error: z.object({
message: z.string(),
error_code: z.number(),
suggested_fix: z.string().nullable(),
errors: z.array(z.string()).nullable(),
}),
});
/**
* Delete operation response
*/
export const ZDeleteOperationResponse = z.object({
success: z.boolean(),
});
/**
* Generic list response
*/
export const ZListResponse = <T extends z.ZodTypeAny>(schema: T) => z.object({
items: z.array(schema),
next_cursor: z.string().nullable(),
total_pages: z.number(),
current_page: z.number(),
total_items: z.number(),
});
/**
* Execute action request
*/
export const ZExecuteActionRequest = z.object({
action: z.string(),
connected_account_id: z.string(),
input: z.record(z.string(), z.unknown()),
});
/**
* Execute action response
*/
export const ZExecuteActionResponse = z.object({
success: z.boolean(),
data: z.unknown(),
error: z.string().optional(),
});
/**
* Local connected account storage schema
*/
export const ZLocalConnectedAccount = z.object({
id: z.string(),
authConfigId: z.string(),
status: ZConnectedAccountStatus,
toolkitSlug: z.string(),
createdAt: z.string(),
lastUpdatedAt: z.string(),
});
export type AuthScheme = z.infer<typeof ZAuthScheme>;
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
export type Toolkit = z.infer<typeof ZToolkit>;
export type Tool = z.infer<typeof ZTool>;
export type AuthConfig = z.infer<typeof ZAuthConfig>;
export type ConnectedAccount = z.infer<typeof ZConnectedAccount>;
export type LocalConnectedAccount = z.infer<typeof ZLocalConnectedAccount>;
export type ExecuteActionRequest = z.infer<typeof ZExecuteActionRequest>;
export type ExecuteActionResponse = z.infer<typeof ZExecuteActionResponse>;

View file

@ -244,6 +244,85 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// Composio integration channels
'composio:is-configured': {
req: z.null(),
res: z.object({
configured: z.boolean(),
}),
},
'composio:set-api-key': {
req: z.object({
apiKey: z.string(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
'composio:initiate-connection': {
req: z.object({
toolkitSlug: z.string(),
}),
res: z.object({
success: z.boolean(),
redirectUrl: z.string().optional(),
connectedAccountId: z.string().optional(),
error: z.string().optional(),
}),
},
'composio:get-connection-status': {
req: z.object({
toolkitSlug: z.string(),
}),
res: z.object({
isConnected: z.boolean(),
status: z.string().optional(),
}),
},
'composio:sync-connection': {
req: z.object({
toolkitSlug: z.string(),
connectedAccountId: z.string(),
}),
res: z.object({
status: z.string(),
}),
},
'composio:disconnect': {
req: z.object({
toolkitSlug: z.string(),
}),
res: z.object({
success: z.boolean(),
}),
},
'composio:list-connected': {
req: z.null(),
res: z.object({
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({
success: z.boolean(),
data: z.unknown(),
error: z.string().optional(),
}),
},
'composio:didConnect': {
req: z.object({
toolkitSlug: z.string(),
success: z.boolean(),
error: z.string().optional(),
}),
res: z.null(),
},
} as const;
// ============================================================================

71
apps/x/pnpm-lock.yaml generated
View file

@ -290,6 +290,9 @@ importers:
'@ai-sdk/provider':
specifier: ^2.0.0
version: 2.0.1
'@composio/core':
specifier: ^0.1.48
version: 0.1.55(zod@4.2.1)
'@google-cloud/local-auth':
specifier: ^3.0.1
version: 3.0.1(encoding@0.1.13)
@ -661,6 +664,19 @@ packages:
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
'@composio/client@0.1.0-alpha.37':
resolution: {integrity: sha512-2YzXiRXlxqOgEz7nEh1aKdJ9vwQRb69+RX6UH+LTDmuTwW+pOjI2qbhIBxTbP2z9e2SyHVG0/AtMxc9tR5jU0w==}
'@composio/core@0.1.55':
resolution: {integrity: sha512-GLSWTS/gZeycQ7W2wSZQ21DKV+LC6WTAilhSj+JG9Apslx3re9luF1Lyblm4UMSf4DzYWKOrtipwQOaeg7bmTg==}
peerDependencies:
zod: '>=3.25.76 <5'
'@composio/json-schema-to-zod@0.1.16':
resolution: {integrity: sha512-vu6RUQTWDW/0wLDWsQHtWJ97JsnMjA1olTQwVD88BAtKZTdDLuzjnGRxSllxp5JG/GntnovUphraZWcWfe80eQ==}
peerDependencies:
zod: '>=3.25.76 <5'
'@electron-forge/cli@7.11.1':
resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==}
engines: {node: '>= 16.4.0'}
@ -5253,6 +5269,18 @@ packages:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'}
openai@5.23.2:
resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openid-client@6.8.1:
resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==}
@ -5555,6 +5583,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pusher-js@8.4.0:
resolution: {integrity: sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==}
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
@ -6152,6 +6183,9 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -6305,6 +6339,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
@ -7201,6 +7239,27 @@ snapshots:
'@chevrotain/utils@11.0.3': {}
'@composio/client@0.1.0-alpha.37': {}
'@composio/core@0.1.55(zod@4.2.1)':
dependencies:
'@composio/client': 0.1.0-alpha.37
'@composio/json-schema-to-zod': 0.1.16(zod@4.2.1)
'@types/json-schema': 7.0.15
chalk: 4.1.2
openai: 5.23.2(zod@4.2.1)
pusher-js: 8.4.0
semver: 7.7.3
uuid: 13.0.0
zod: 4.2.1
zod-to-json-schema: 3.25.1(zod@4.2.1)
transitivePeerDependencies:
- ws
'@composio/json-schema-to-zod@0.1.16(zod@4.2.1)':
dependencies:
zod: 4.2.1
'@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.24.2)':
dependencies:
'@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.24.2)
@ -12678,6 +12737,10 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openai@5.23.2(zod@4.2.1):
optionalDependencies:
zod: 4.2.1
openid-client@6.8.1:
dependencies:
jose: 6.1.3
@ -13024,6 +13087,10 @@ snapshots:
punycode@2.3.1: {}
pusher-js@8.4.0:
dependencies:
tweetnacl: 1.0.3
qs@6.14.1:
dependencies:
side-channel: 1.1.0
@ -13730,6 +13797,8 @@ snapshots:
tw-animate-css@1.4.0: {}
tweetnacl@1.0.3: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -13879,6 +13948,8 @@ snapshots:
uuid@11.1.0: {}
uuid@13.0.0: {}
uuid@9.0.1: {}
validate-npm-package-license@3.0.4: