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

@@ -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;