* agent slack

* add to settings

* select workspace

* skill changes

* chain commands

* fix: Use Git Bash on Windows instead of hardcoded /bin/sh for command execution

* Refactor command execution shell handling

Replace the getShell() function with EXECUTION_SHELL for consistency in command execution. This change simplifies the code and aligns with the recent updates from the dev branch.

---------

Co-authored-by: Tushar Magar <tushmag@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkml 2026-03-10 16:26:51 +05:30 committed by GitHub
parent 70635ad245
commit 0be3e60560
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 502 additions and 848 deletions

View file

@ -15,7 +15,11 @@ import { bus } from '@x/core/dist/runs/bus.js';
import { serviceBus } from '@x/core/dist/services/service_bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import z from 'zod';
const execAsync = promisify(exec);
import { RunEvent } from '@x/shared/dist/runs.js';
import { ServiceEvent } from '@x/shared/dist/service-events.js';
import container from '@x/core/dist/di/container.js';
@ -25,6 +29,7 @@ import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
@ -410,6 +415,30 @@ export function setupIpcHandlers() {
return { success: true };
},
'slack:getConfig': async () => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const config = await repo.getConfig();
return { enabled: config.enabled, workspaces: config.workspaces };
},
'slack:setConfig': async (_event, args) => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
return { success: true };
},
'slack:listWorkspaces': async () => {
try {
const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 });
const parsed = JSON.parse(stdout);
const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({
url: w.workspace_url || '',
name: w.workspace_name || '',
}));
return { workspaces };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces';
return { workspaces: [], error: message };
}
},
'onboarding:getStatus': async () => {
// Show onboarding if it hasn't been completed yet
const complete = isOnboardingComplete();

View file

@ -17,7 +17,6 @@ import {
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
import { toast } from "sonner"
@ -55,11 +54,15 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
// Slack state (agent-slack CLI)
const [slackEnabled, setSlackEnabled] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// Load available providers on mount
useEffect(() => {
@ -107,76 +110,76 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
}
}, [])
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
// Load Slack config
const refreshSlackConfig = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
const result = await window.ipc.invoke('slack:getConfig', null)
setSlackEnabled(result.enabled)
setSlackWorkspaces(result.workspaces || [])
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
console.error('Failed to load Slack config:', error)
setSlackEnabled(false)
setSlackWorkspaces([])
} finally {
setSlackLoading(false)
}
}, [])
// Connect to Slack via Composio
const startSlackConnect = useCallback(async () => {
// Enable Slack: discover workspaces
const handleSlackEnable = useCallback(async () => {
setSlackDiscovering(true)
setSlackDiscoverError(null)
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)
const result = await window.ipc.invoke('slack:listWorkspaces', null)
if (result.error || result.workspaces.length === 0) {
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
setSlackAvailableWorkspaces([])
setSlackPickerOpen(true)
} else {
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
setSlackPickerOpen(true)
}
// 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)
console.error('Failed to discover Slack workspaces:', error)
setSlackDiscoverError('Failed to discover Slack workspaces')
setSlackPickerOpen(true)
} finally {
setSlackDiscovering(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 () => {
// Save selected Slack workspaces
const handleSlackSaveWorkspaces = useCallback(async () => {
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
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')
}
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
setSlackEnabled(true)
setSlackWorkspaces(selected)
setSlackPickerOpen(false)
toast.success('Slack enabled')
} catch (error) {
console.error('Failed to disconnect from Slack:', error)
toast.error('Failed to disconnect from Slack')
console.error('Failed to save Slack config:', error)
toast.error('Failed to save Slack settings')
} finally {
setSlackLoading(false)
}
}, [slackAvailableWorkspaces, slackSelectedUrls])
// Disable Slack
const handleSlackDisable = useCallback(async () => {
try {
setSlackLoading(true)
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
setSlackEnabled(false)
setSlackWorkspaces([])
setSlackPickerOpen(false)
toast.success('Slack disabled')
} catch (error) {
console.error('Failed to update Slack config:', error)
toast.error('Failed to update Slack settings')
} finally {
setSlackLoading(false)
}
@ -187,8 +190,8 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
// Refresh Granola
refreshGranolaConfig()
// Refresh Slack status
refreshSlackStatus()
// Refresh Slack config
refreshSlackConfig()
// Refresh OAuth providers
if (providers.length === 0) return
@ -226,7 +229,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackStatus])
}, [providers, refreshGranolaConfig, refreshSlackConfig])
// Refresh statuses when popover opens or providers list changes
useEffect(() => {
@ -270,26 +273,6 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
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,
@ -574,62 +557,90 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha
</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 className="rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center justify-between gap-3">
<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>
{slackEnabled && slackWorkspaces.length > 0 ? (
<span className="text-xs text-muted-foreground truncate">
{slackWorkspaces.map(w => w.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</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>
<div className="shrink-0 flex items-center gap-2">
{(slackLoading || slackDiscovering) && (
<Loader2 className="size-3 animate-spin" />
)}
{slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => handleSlackDisable()}
disabled={slackLoading}
/>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
<Button
variant="default"
size="sm"
onClick={handleSlackEnable}
disabled={slackLoading || slackDiscovering}
className="h-7 px-2 text-xs"
>
Enable
</Button>
)}
</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>
{slackPickerOpen && (
<div className="mt-2 ml-11 space-y-2">
{slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
) : (
<>
{slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={slackSelectedUrls.has(w.url)}
onChange={(e) => {
setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={handleSlackSaveWorkspaces}
disabled={slackSelectedUrls.size === 0 || slackLoading}
className="h-7 px-3 text-xs"
>
Save
</Button>
</>
)}
</div>
)}
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
<ComposioApiKeyModal
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
/>
</>
)
}

View file

@ -2,8 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail, CheckCircle2 } from "lucide-react"
// import { MessageSquare } from "lucide-react"
import { Loader2, Mic, Mail, CheckCircle2, MessageSquare } from "lucide-react"
import {
Dialog,
@ -23,7 +22,6 @@ import {
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
import { toast } from "sonner"
@ -80,11 +78,15 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [granolaLoading, setGranolaLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
// const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
// Slack state (agent-slack CLI)
const [slackEnabled, setSlackEnabled] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackWorkspaces, setSlackWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState<Array<{ url: string; name: string }>>([])
const [slackSelectedUrls, setSlackSelectedUrls] = useState<Set<string>>(new Set())
const [slackPickerOpen, setSlackPickerOpen] = useState(false)
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
@ -212,63 +214,80 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
}, [])
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
// Load Slack config
const refreshSlackConfig = useCallback(async () => {
try {
// setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
setSlackLoading(true)
const result = await window.ipc.invoke('slack:getConfig', null)
setSlackEnabled(result.enabled)
setSlackWorkspaces(result.workspaces || [])
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
console.error('Failed to load Slack config:', error)
setSlackEnabled(false)
setSlackWorkspaces([])
} finally {
// setSlackLoading(false)
setSlackLoading(false)
}
}, [])
// Start Slack connection
const startSlackConnect = useCallback(async () => {
// Enable Slack: discover workspaces
const handleSlackEnable = useCallback(async () => {
setSlackDiscovering(true)
setSlackDiscoverError(null)
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)
const result = await window.ipc.invoke('slack:listWorkspaces', null)
if (result.error || result.workspaces.length === 0) {
setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop')
setSlackAvailableWorkspaces([])
setSlackPickerOpen(true)
} else {
setSlackAvailableWorkspaces(result.workspaces)
setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url)))
setSlackPickerOpen(true)
}
// 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)
console.error('Failed to discover Slack workspaces:', error)
setSlackDiscoverError('Failed to discover Slack workspaces')
setSlackPickerOpen(true)
} finally {
setSlackDiscovering(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) => {
// Save selected Slack workspaces
const handleSlackSaveWorkspaces = useCallback(async () => {
const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url))
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()
setSlackLoading(true)
await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected })
setSlackEnabled(true)
setSlackWorkspaces(selected)
setSlackPickerOpen(false)
toast.success('Slack enabled')
} catch (error) {
console.error('Failed to save Composio API key:', error)
toast.error('Failed to save API key')
console.error('Failed to save Slack config:', error)
toast.error('Failed to save Slack settings')
} finally {
setSlackLoading(false)
}
}, [startSlackConnect])
}, [slackAvailableWorkspaces, slackSelectedUrls])
// Disable Slack
const handleSlackDisable = useCallback(async () => {
try {
setSlackLoading(true)
await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] })
setSlackEnabled(false)
setSlackWorkspaces([])
setSlackPickerOpen(false)
toast.success('Slack disabled')
} catch (error) {
console.error('Failed to update Slack config:', error)
toast.error('Failed to update Slack settings')
} finally {
setSlackLoading(false)
}
}, [])
const handleNext = () => {
if (currentStep < 2) {
@ -319,8 +338,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Refresh Granola
refreshGranolaConfig()
// Refresh Slack status
refreshSlackStatus()
// Refresh Slack config
refreshSlackConfig()
// Refresh OAuth providers
if (providers.length === 0) return
@ -349,7 +368,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
setProviderStates(newStates)
}, [providers, refreshGranolaConfig, refreshSlackStatus])
}, [providers, refreshGranolaConfig, refreshSlackConfig])
// Refresh statuses when modal opens or providers list changes
useEffect(() => {
@ -383,26 +402,6 @@ 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,
@ -546,50 +545,85 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)
// 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 className="rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center justify-between gap-3">
<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>
{slackEnabled && slackWorkspaces.length > 0 ? (
<span className="text-xs text-muted-foreground truncate">
{slackWorkspaces.map(w => w.name).join(', ')}
</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</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>
<div className="shrink-0 flex items-center gap-2">
{(slackLoading || slackDiscovering) && (
<Loader2 className="size-3 animate-spin" />
)}
{slackEnabled ? (
<Switch
checked={true}
onCheckedChange={() => handleSlackDisable()}
disabled={slackLoading}
/>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
<Button
variant="default"
size="sm"
onClick={handleSlackEnable}
disabled={slackLoading || slackDiscovering}
>
Enable
</Button>
)}
</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>
{slackPickerOpen && (
<div className="mt-2 ml-13 space-y-2">
{slackDiscoverError ? (
<p className="text-xs text-muted-foreground">{slackDiscoverError}</p>
) : (
<>
{slackAvailableWorkspaces.map(w => (
<label key={w.url} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={slackSelectedUrls.has(w.url)}
onChange={(e) => {
setSlackSelectedUrls(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(w.url)
else next.delete(w.url)
return next
})
}}
className="rounded border-border"
/>
<span className="truncate">{w.name}</span>
</label>
))}
<Button
size="sm"
onClick={handleSlackSaveWorkspaces}
disabled={slackSelectedUrls.size === 0 || slackLoading}
>
Save
</Button>
</>
)}
</div>
)}
</div>
)
*/
// Step 0: LLM Setup
const renderLlmSetupStep = () => {
@ -820,6 +854,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
{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>
@ -837,7 +878,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Step 2: Completion
const renderCompletionStep = () => {
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled
return (
<div className="flex flex-col items-center text-center">
@ -878,7 +919,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<span>Granola (Local meeting notes)</span>
</div>
)}
{slackConnected && (
{slackEnabled && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Slack (Team communication)</span>
@ -904,12 +945,6 @@ 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

@ -33,7 +33,7 @@ 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.
**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 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.

View file

@ -1,121 +1,124 @@
import { slackToolCatalogMarkdown } from "./tool-catalog.js";
const skill = String.raw`
# Slack Integration Skill
# Slack Integration Skill (agent-slack CLI)
You can interact with Slack to help users communicate with their team. This includes sending messages, viewing channel history, finding users, and searching conversations.
You interact with Slack by running **agent-slack** commands through \`executeCommand\`.
## Prerequisites
---
## 1. Check Connection
Before any Slack operation, read \`~/.rowboat/config/slack.json\`. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
If enabled, use the workspace URLs from the config for all commands.
---
## 2. Core Commands
### Messages
| Action | Command |
|--------|---------|
| List recent messages | \`agent-slack message list "#channel-name" --limit 25\` |
| List thread replies | \`agent-slack message list "#channel" --thread-ts 1234567890.123456\` |
| Get a single message | \`agent-slack message get "https://team.slack.com/archives/C.../p..."\` |
| Send a message | \`agent-slack message send "#channel-name" "Hello team!"\` |
| Reply in thread | \`agent-slack message send "#channel-name" "Reply text" --thread-ts 1234567890.123456\` |
| Edit a message | \`agent-slack message edit "#channel-name" --ts 1234567890.123456 "Updated text"\` |
| Delete a message | \`agent-slack message delete "#channel-name" --ts 1234567890.123456\` |
**Targets** can be:
- A full Slack URL: \`https://team.slack.com/archives/C01234567/p1234567890123456\`
- A channel name: \`"#general"\` or \`"general"\`
- A channel ID: \`C01234567\`
### Reactions
Before using Slack tools, ALWAYS check if Slack is connected:
\`\`\`
slack-checkConnection({})
agent-slack message react add "<target>" <emoji> --ts <ts>
agent-slack message react remove "<target>" <emoji> --ts <ts>
\`\`\`
If not connected, inform the user they need to connect Slack from the settings/onboarding.
### Search
## 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({})
agent-slack search messages "query text" --limit 20
agent-slack search messages "query" --channel "#channel-name" --user "@username"
agent-slack search messages "query" --after 2025-01-01 --before 2025-02-01
agent-slack search files "query" --limit 10
\`\`\`
### 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.
### Channels
## Common Tasks
\`\`\`
agent-slack channel new --name "project-x" --workspace https://team.slack.com
agent-slack channel new --name "secret-project" --private
agent-slack channel invite --channel "#project-x" --users "@alice,@bob"
\`\`\`
### 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 })\`
### Users
### Send a Message
1. Draft the message and show it to the user
2. ONLY after user approval, send using \`slack-sendMessage\`
\`\`\`
agent-slack user list --limit 200
agent-slack user get "@username"
agent-slack user get U01234567
\`\`\`
### Search Messages
1. Use \`slack-searchMessages({ query: "...", count: 20 })\`
### Canvases
\`\`\`
agent-slack canvas get "https://team.slack.com/docs/F01234567"
agent-slack canvas get F01234567 --workspace https://team.slack.com
\`\`\`
---
## 3. Multi-Workspace
**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces.
If the selected workspace list contains multiple entries, use \`--workspace <url>\` to disambiguate:
\`\`\`
agent-slack message list "#general" --workspace https://team.slack.com
\`\`\`
If only one workspace is selected, always use \`--workspace\` with its URL to avoid ambiguity with other authenticated workspaces.
---
## 4. Token Budget Control
Use \`--limit\` to control how many messages/results are returned. Use \`--max-body-chars\` or \`--max-content-chars\` to truncate long message bodies:
\`\`\`
agent-slack message list "#channel" --limit 10
agent-slack search messages "query" --limit 5 --max-content-chars 2000
\`\`\`
---
## 5. Discovering More Commands
For any command you're unsure about:
\`\`\`
agent-slack --help
agent-slack message --help
agent-slack search --help
agent-slack channel --help
\`\`\`
---
## 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
- **Always show drafts before sending** Never send Slack messages without user confirmation
- **Summarize, don't dump** When showing channel history, summarize the key points rather than pasting everything
- **Prefer Slack URLs** When referring to messages, use Slack URLs over raw channel names when available
- **Use --limit** Always set reasonable limits to keep output concise and token-efficient
- **Resolve user IDs** Messages contain raw user IDs like \`U078AHJP341\`. Resolve them to real names before presenting to the user. Batch all lookups into a single \`executeCommand\` call using \`;\` separators, e.g. \`agent-slack user get U078AHJP341 --workspace ... ; agent-slack user get U090UEZCEQ0 --workspace ...\`
- **Cross-reference with knowledge base** Check if mentioned people have notes in the knowledge base
`;
export default skill;

View file

@ -1,117 +0,0 @@
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

@ -12,9 +12,6 @@ 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";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
import { createProvider } from "../../models/models.js";
@ -36,232 +33,6 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({
isAvailable: z.custom<() => Promise<boolean>>().optional(),
}));
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;
};
const LLMPARSE_MIME_TYPES: Record<string, string> = {
'.pdf': 'application/pdf',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@ -1109,164 +880,6 @@ 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 });
},
},
// ============================================================================
// Web Search (Brave Search API)
// ============================================================================

View file

@ -4,6 +4,7 @@ import { getSecurityAllowList } from '../../config/security.js';
import { getExecutionShell } from '../assistant/runtime-context.js';
const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);

View file

@ -14,6 +14,7 @@ import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/re
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -37,6 +38,7 @@ container.register({
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
});
export default container;

View file

@ -0,0 +1,41 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { SlackConfig } from './types.js';
export interface ISlackConfigRepo {
getConfig(): Promise<SlackConfig>;
setConfig(config: SlackConfig): Promise<void>;
}
export class FSSlackConfigRepo implements ISlackConfigRepo {
private readonly configPath = path.join(WorkDir, 'config', 'slack.json');
private readonly defaultConfig: SlackConfig = { enabled: false, workspaces: [] };
constructor() {
this.ensureConfigFile();
}
private async ensureConfigFile(): Promise<void> {
try {
await fs.access(this.configPath);
} catch {
await fs.writeFile(this.configPath, JSON.stringify(this.defaultConfig, null, 2));
}
}
async getConfig(): Promise<SlackConfig> {
try {
const content = await fs.readFile(this.configPath, 'utf8');
const parsed = JSON.parse(content);
return SlackConfig.parse(parsed);
} catch {
return this.defaultConfig;
}
}
async setConfig(config: SlackConfig): Promise<void> {
const validated = SlackConfig.parse(config);
await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
}
}

View file

@ -0,0 +1,13 @@
import z from "zod";
export const SlackWorkspace = z.object({
url: z.string(),
name: z.string(),
});
export type SlackWorkspace = z.infer<typeof SlackWorkspace>;
export const SlackConfig = z.object({
enabled: z.boolean(),
workspaces: z.array(SlackWorkspace).default([]),
});
export type SlackConfig = z.infer<typeof SlackConfig>;

View file

@ -271,6 +271,29 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
'slack:getConfig': {
req: z.null(),
res: z.object({
enabled: z.boolean(),
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
}),
},
'slack:setConfig': {
req: z.object({
enabled: z.boolean(),
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
}),
res: z.object({
success: z.literal(true),
}),
},
'slack:listWorkspaces': {
req: z.null(),
res: z.object({
workspaces: z.array(z.object({ url: z.string(), name: z.string() })),
error: z.string().optional(),
}),
},
'onboarding:getStatus': {
req: z.null(),
res: z.object({