Integrate Composio for external tool access

Agents can now call external tools (GitHub, Slack, Gmail, etc.) via
Composio's managed API. A thin HTTP client calls the Composio REST API
directly — no heavy SDK dependency. External tools are loaded per-org
from composio_settings and merged with internal tools at run time. The
agent run loop routes composio_* tool calls to the Composio execution
endpoint while internal tools dispatch as before.

Includes: migration 043 for composio_settings table, IPC handler for
saving Composio API key and toolkit configuration, and the composio
client/bridge modules.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-30 21:31:04 -04:00
1 parent 65c5bec9a3
commit 77f191fa25
12 files changed
+263 -10

No files matched your search

@@ -316,6 +316,19 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
});
ipcMain.handle(IPC_CHANNELS.saveComposioSettings, (_event, input) => mutate(() => {
const now = new Date().toISOString();
const db = deps.connection.db;
const upsert = db.prepare(`
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
`);
upsert.run(input.orgId, "api_key", input.apiKey, now);
upsert.run(input.orgId, "toolkits", JSON.stringify(input.toolkits), now);
upsert.run(input.orgId, "entity_id", input.orgId, now);
return null;
}));
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close());
@@ -0,0 +1,9 @@
export const composioIntegrationSchema = `
create table if not exists composio_settings (
org_id text not null references organizations(id) on delete cascade,
key text not null,
value text not null,
updated_at text not null,
primary key (org_id, key)
);
`;
@@ -41,6 +41,7 @@ import { auditImmutabilityOrgTeardownSchema } from "./migrations/039-audit-immut
import { documentsKnowledgeAutomationSchema } from "./migrations/040-documents-knowledge-automation";
import { taskSubtasksDeleteSchema } from "./migrations/041-task-subtasks-delete";
import { approvalNotesMeetingsSprintsSchema } from "./migrations/042-approval-notes-meetings-sprints";
import { composioIntegrationSchema } from "./migrations/043-composio-integration";
interface Migration {
version: number;
@@ -91,6 +92,7 @@ const MIGRATIONS: Migration[] = [
{ version: 40, name: "documents_knowledge_automation", sql: documentsKnowledgeAutomationSchema },
{ version: 41, name: "task_subtasks_delete", sql: taskSubtasksDeleteSchema },
{ version: 42, name: "approval_notes_meetings_sprints", sql: approvalNotesMeetingsSprintsSchema },
{ version: 43, name: "composio_integration", sql: composioIntegrationSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1,7 +1,8 @@
import type { ProviderService } from "./provider-service";
import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools";
import { canonicalMentionHandle } from "../../shared/mention-handles";
import type { AgentTurnMessage } from "./provider-tool-turn";
import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn";
import { isComposioTool, executeComposioTool } from "./composio/composio-tool-bridge";
const MAX_ITERATIONS = 12;
@@ -12,6 +13,7 @@ export interface AgentRunLoopInput {
instructions: string;
initialUserTurn: string;
toolContext: AgentToolContext;
externalTools?: ToolDefinition[];
}
export interface AgentRunLoopResult {
@@ -35,6 +37,7 @@ export interface AgentRunLoopDeps {
* iteration cap is hit (which throws rather than silently truncating).
*/
export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopInput): Promise<AgentRunLoopResult> {
const allTools = input.externalTools ? [...AGENT_TOOLS, ...input.externalTools] : AGENT_TOOLS;
const messages: AgentTurnMessage[] = [{ role: "user", text: input.initialUserTurn }];
let toolCallCount = 0;
let lastRequestId = "";
@@ -46,7 +49,7 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
workspaceId: input.workspaceId,
instructions: input.instructions,
messages,
tools: AGENT_TOOLS,
tools: allTools,
});
lastRequestId = turn.requestId;
@@ -61,16 +64,19 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
}
messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls });
const results = turn.toolCalls.map((call) => {
const results: Array<{ toolCallId: string; toolName: string; output: string }> = [];
for (const call of turn.toolCalls) {
toolCallCount += 1;
return {
toolCallId: call.id,
toolName: call.name,
output: dispatchAgentTool(deps.tools, input.toolContext, call, {
let output: string;
if (isComposioTool(call.name)) {
output = await executeComposioTool(deps.tools.db, input.orgId, call);
} else {
output = dispatchAgentTool(deps.tools, input.toolContext, call, {
announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls),
}),
};
});
});
}
results.push({ toolCallId: call.id, toolName: call.name, output });
}
messages.push({ role: "tool", results });
deps.onToolResults?.();
}
@@ -0,0 +1,124 @@
import { net } from "electron";
const BASE_URL = "https://backend.composio.dev/api/v3";
export interface ComposioToolSchema {
name: string;
description: string;
parameters: {
type: string;
properties: Record<string, unknown>;
required?: string[];
};
}
export interface ComposioToolExecutionResult {
data: Record<string, unknown>;
error: string | null;
}
export class ComposioClient {
constructor(private readonly apiKey: string) {}
async getTools(toolkits: string[]): Promise<ComposioToolSchema[]> {
const params = new URLSearchParams();
for (const toolkit of toolkits) params.append("toolkit", toolkit);
params.set("limit", "50");
const url = `${BASE_URL}/tools?${params.toString()}`;
const response = await this.request("GET", url);
if (!Array.isArray(response.items)) return [];
return (response.items as Array<Record<string, unknown>>).map(normalizeToolSchema);
}
async executeTool(input: {
toolName: string;
arguments: Record<string, unknown>;
entityId: string;
connectedAccountId?: string;
}): Promise<ComposioToolExecutionResult> {
const url = `${BASE_URL}/tools/${encodeURIComponent(input.toolName)}/execute`;
const body: Record<string, unknown> = {
entity_id: input.entityId,
input: input.arguments,
};
if (input.connectedAccountId) body.connected_account_id = input.connectedAccountId;
const response = await this.request("POST", url, body);
return {
data: typeof response === "object" && response !== null ? response as Record<string, unknown> : {},
error: null,
};
}
async getConnectedAccounts(entityId: string): Promise<Array<{ id: string; appName: string; status: string }>> {
const params = new URLSearchParams({ entity_id: entityId, showActiveOnly: "true" });
const url = `${BASE_URL}/connectedAccounts?${params.toString()}`;
const response = await this.request("GET", url);
if (!Array.isArray(response.items)) return [];
return (response.items as Array<Record<string, unknown>>).map((item) => ({
id: String(item.id ?? ""),
appName: String(item.appName ?? item.app_name ?? ""),
status: String(item.status ?? ""),
}));
}
async initiateConnection(input: {
entityId: string;
appName: string;
redirectUrl?: string;
}): Promise<{ redirectUrl: string; connectedAccountId: string }> {
const url = `${BASE_URL}/connectedAccounts`;
const body: Record<string, unknown> = {
entity_id: input.entityId,
integration_id: input.appName,
};
if (input.redirectUrl) body.redirect_url = input.redirectUrl;
const response = await this.request("POST", url, body);
return {
redirectUrl: String(response.redirectUrl ?? response.redirect_url ?? ""),
connectedAccountId: String(response.connectedAccountId ?? response.id ?? ""),
};
}
private async request(method: string, url: string, body?: Record<string, unknown>): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const request = net.request({ method, url });
request.setHeader("x-api-key", this.apiKey);
request.setHeader("Accept", "application/json");
if (body) request.setHeader("Content-Type", "application/json");
let responseData = "";
request.on("response", (response) => {
response.on("data", (chunk) => { responseData += chunk.toString(); });
response.on("end", () => {
if (response.statusCode && response.statusCode >= 400) {
reject(new Error(`Composio API ${method} ${url} returned ${response.statusCode}: ${responseData.slice(0, 200)}`));
return;
}
try {
resolve(JSON.parse(responseData) as Record<string, unknown>);
} catch {
reject(new Error(`Composio API returned non-JSON response: ${responseData.slice(0, 200)}`));
}
});
response.on("error", reject);
});
request.on("error", reject);
if (body) request.write(JSON.stringify(body));
request.end();
});
}
}
function normalizeToolSchema(raw: Record<string, unknown>): ComposioToolSchema {
const params = raw.parameters ?? raw.inputSchema ?? raw.input_schema ?? {};
const schema = typeof params === "object" && params !== null ? params as Record<string, unknown> : {};
return {
name: String(raw.name ?? raw.slug ?? ""),
description: String(raw.description ?? raw.display_name ?? ""),
parameters: {
type: "object",
properties: (schema.properties ?? {}) as Record<string, unknown>,
required: Array.isArray(schema.required) ? schema.required as string[] : [],
},
};
}
@@ -0,0 +1,86 @@
import type { DatabaseSync } from "node:sqlite";
import type { ToolDefinition, ToolCall } from "../provider-tool-turn";
import { ComposioClient, type ComposioToolSchema } from "./composio-client";
const COMPOSIO_TOOL_PREFIX = "composio_";
export function isComposioTool(toolName: string): boolean {
return toolName.startsWith(COMPOSIO_TOOL_PREFIX);
}
export function composioToolName(composioName: string): string {
return `${COMPOSIO_TOOL_PREFIX}${composioName.toLowerCase()}`;
}
function originalToolName(prefixedName: string): string {
return prefixedName.slice(COMPOSIO_TOOL_PREFIX.length).toUpperCase();
}
export function composioSchemaToToolDefinition(schema: ComposioToolSchema): ToolDefinition {
return {
name: composioToolName(schema.name),
description: schema.description,
parameters: {
type: "object",
additionalProperties: false,
properties: schema.parameters.properties,
required: schema.parameters.required ?? [],
},
};
}
export async function loadComposioTools(db: DatabaseSync, orgId: string): Promise<ToolDefinition[]> {
const apiKey = getComposioApiKey(db, orgId);
if (!apiKey) return [];
const toolkits = getEnabledToolkits(db, orgId);
if (toolkits.length === 0) return [];
const client = new ComposioClient(apiKey);
const schemas = await client.getTools(toolkits);
return schemas.map(composioSchemaToToolDefinition);
}
export async function executeComposioTool(
db: DatabaseSync,
orgId: string,
call: ToolCall,
): Promise<string> {
const apiKey = getComposioApiKey(db, orgId);
if (!apiKey) return "ERROR: Composio API key is not configured.";
const toolName = originalToolName(call.name);
const entityId = getComposioEntityId(db, orgId);
const client = new ComposioClient(apiKey);
const result = await client.executeTool({
toolName,
arguments: call.arguments,
entityId,
});
if (result.error) return `ERROR: ${result.error}`;
return JSON.stringify(result.data);
}
function getComposioApiKey(db: DatabaseSync, orgId: string): string | null {
const row = db.prepare(
"select value from composio_settings where org_id = ? and key = 'api_key' limit 1",
).get(orgId) as { value: string } | undefined;
return row?.value ?? null;
}
function getComposioEntityId(db: DatabaseSync, orgId: string): string {
const row = db.prepare(
"select value from composio_settings where org_id = ? and key = 'entity_id' limit 1",
).get(orgId) as { value: string } | undefined;
return row?.value ?? orgId;
}
function getEnabledToolkits(db: DatabaseSync, orgId: string): string[] {
const row = db.prepare(
"select value from composio_settings where org_id = ? and key = 'toolkits' limit 1",
).get(orgId) as { value: string } | undefined;
if (!row?.value) return [];
try {
const parsed = JSON.parse(row.value);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : [];
} catch {
return [];
}
}
@@ -22,6 +22,7 @@ import {
type TaskRunIsolation,
type WorkspaceRow,
} from "./task-prompts";
import { loadComposioTools } from "./composio/composio-tool-bridge";
const TASK_RUN_STATE = {
QUEUED: "queued",
@@ -121,6 +122,7 @@ export class TaskExecutionService {
const chatContext = this.loadChatContext(orgId, isolation);
const isCeo = !agent.manager_agent_id;
const hasReports = this.agentHasReports(orgId, agent.id);
const externalTools = await loadComposioTools(this.db, orgId);
const result = await runAgentLoop(
{
providers: this.providers,
@@ -147,6 +149,7 @@ export class TaskExecutionService {
chatContext,
}),
toolContext,
externalTools,
},
);
providerRequestId = result.lastRequestId;
+1
View File
@@ -82,6 +82,7 @@ const api: DesktopApi = {
setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input),
createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input),
setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input),
saveComposioSettings: (input) => invoke(IPC_CHANNELS.saveComposioSettings, input),
search: (input) => invoke(IPC_CHANNELS.search, input),
subscribe: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload);
@@ -0,0 +1,5 @@
export interface SaveComposioSettingsInput {
orgId: string;
apiKey: string;
toolkits: string[];
}
@@ -7,3 +7,4 @@ export * from "./files";
export * from "./governance";
export * from "./collaboration";
export * from "./onboarding";
export * from "./composio";
@@ -63,6 +63,7 @@ import type {
RunTaskInput,
RevokeProviderCredentialInput,
SaveProviderCredentialInput,
SaveComposioSettingsInput,
SearchInput,
SendMessageInput,
SetActiveOrganizationInput,
@@ -172,6 +173,7 @@ export interface DesktopApi {
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
saveComposioSettings(input: SaveComposioSettingsInput): Promise<DesktopResult<AppSnapshot>>;
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
subscribe(callback: (event: RendererEvent) => void): () => void;
platform: "win32" | "darwin" | "linux";
+1
View File
@@ -74,6 +74,7 @@ export const IPC_CHANNELS = {
deleteMeeting: "agentcompany:delete-meeting",
createSprint: "agentcompany:create-sprint",
deleteSprint: "agentcompany:delete-sprint",
saveComposioSettings: "agentcompany:save-composio-settings",
search: "agentcompany:search",
rendererEvent: "agentcompany:renderer-event",
windowMinimize: "agentcompany:window-minimize",