Composio Integration Architecture: Triggers, API Connections & White-Label Auth
Status: Architecture Proposal (Draft) Author: bot0 team Date: 2026-02-24 Dependencies: Daemon tool system, Proxy security model, Proactive engine, Hub relay, Cron scheduler
Table of Contents
- Executive Summary
- Why Composio
- Architecture Overview
- Component Design
- Security Model
- Data Model
- White-Label Authentication
- Trigger Architecture (Deep Dive)
- Multi-Account Management
- Permission System
- Implementation Plan
- Open Questions
Executive Summary
This document proposes integrating Composio into bot0 to unlock:
- 10,000+ API connections — Gmail, Slack, GitHub, Salesforce, HubSpot, Google Drive, Notion, Linear, Jira, and thousands more via pre-built toolkits
- Triggers — Real-time event subscriptions (webhooks + polling) that feed into the proactive engine, making the agent truly reactive to the world
- White-label OAuth — Users authenticate with services through bot0-branded flows, never seeing Composio's domain
- Multi-account support — Users can connect multiple accounts per service (work Gmail + personal Gmail)
Core principle: Composio is an infrastructure layer. The user never knows it exists. They see bot0 connecting to their services, bot0-branded OAuth screens, and bot0 managing their connections. Composio is the plumbing.
Security principle: The Composio API key lives on the Proxy (Bytespace), never on the daemon. The daemon interacts with Composio exclusively through proxied endpoints, maintaining the zero-secrets-on-daemon guarantee.
Why Composio
The Alternative: Building It Ourselves
Building OAuth integrations for even 50 services would require:
- 50+ OAuth app registrations across provider developer portals
- Token refresh logic per provider (each has quirks)
- Webhook endpoint management per service
- Schema maintenance for each API's tool definitions
- Ongoing maintenance as APIs change
What Composio Provides
| Capability | Details |
|---|---|
| Pre-built toolkits | 500+ apps with tools ready to use |
| Managed OAuth | Token refresh, expiry handling, re-auth prompts |
| Vercel AI SDK provider | @composio/vercel — tools with built-in execute, works directly with streamText() |
| Trigger infrastructure | Webhook + polling triggers with signature verification |
| MCP support | Alternative integration path via MCP protocol |
| User isolation | Per-user_id credential and tool scoping |
| White-label auth | Custom OAuth apps, branded auth screens, custom redirect URIs |
| SOC 2 Type 2 | Enterprise-grade security compliance |
Pricing Reality
| Plan | Users | API Calls/mo | Cost |
|---|---|---|---|
| Hobby | 100 | 5K-20K | Free |
| Starter | 1,000 | 100K | $99/mo |
| Growth | 5,000 | 500K | $199/mo |
| Enterprise | Custom | Custom | Custom |
For bot0's usage pattern (agent-driven API calls), the Growth plan at $199/mo covers 5,000 users with 500K API calls — extremely cost-effective compared to building and maintaining integrations ourselves.
Architecture Overview
High-Level Data Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ DESKTOP / CLI │
│ │
│ User: "Connect my Google Drive" │
│ Agent: Opens auth URL → User authenticates → Connection saved │
│ │
│ User: "Send a Slack message to #general" │
│ Agent: Uses bytespace_execute tool → Proxied to Composio → Slack API │
│ │
│ User: "Notify me when I get a GitHub PR review" │
│ Agent: Creates trigger → Webhook configured → Events flow to daemon │
│ │
└──────────────────────────────┬──────────────────────────────────────────────┘
│ IPC
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ DAEMON │
│ │
│ ┌──────────────────┐ ┌───────────────────┐ ┌─────────────────────┐ │
│ │ bytespace_search │ │ bytespace_execute │ │ bytespace_connect │ │
│ │ │ │ │ │ │ │
│ │ Discover tools │ │ Run actions on │ │ Initiate OAuth for │ │
│ │ across 500+ apps │ │ connected services │ │ a service │ │
│ └──────┬───────────┘ └──────┬─────────────┘ └──────┬──────────────┘ │
│ │ │ │ │
│ ┌──────┴─────────────────────┴────────────────────────┴──────────────┐ │
│ │ ComposioClient (daemon-side) │ │
│ │ • Thin wrapper over proxied HTTP │ │
│ │ • Injects session token + device signature │ │
│ │ • Routes all calls through Bytespace proxy │ │
│ │ • ZERO Composio API key — only session token │ │
│ └────────────────────────────┬────────────────────────────────────────┘ │
│ │ │
└───────────────────────────────┼──────────────────────────────────────────────┘
│ HTTPS + Device Signature
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ BYTESPACE PROXY │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ /api/proxy/composio/[...path] │ │
│ │ │ │
│ │ 1. authenticateProxyRequest() — session + device signature │ │
│ │ 2. Decrypt COMPOSIO_API_KEY from encrypted storage │ │
│ │ 3. Map bot0 userId to Composio userId │ │
│ │ 4. Forward request to Composio API with x-api-key header │ │
│ │ 5. Return response to daemon │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ /api/webhooks/composio (public endpoint) │ │
│ │ │ │
│ │ 1. Verify HMAC-SHA256 webhook signature │ │
│ │ 2. Parse trigger payload (V3 format) │ │
│ │ 3. Resolve user_id → device_id → target daemon │ │
│ │ 4. Route trigger event via Hub to daemon │ │
│ │ 5. OR queue as proactive task if daemon offline │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────┬──────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMPOSIO API │
│ │
│ • Tool discovery & execution │
│ • OAuth flow management │
│ • Trigger subscription & delivery │
│ • Credential storage & refresh │
│ • User isolation │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Trigger Event Flow (The Proactive Path)
External Service (Gmail, GitHub, Slack, etc.)
│
│ Webhook / Poll result
▼
┌─────────────────┐
│ Composio │ Receives event, matches to trigger instance
│ Backend │ Delivers to configured webhook URL
└────────┬────────┘
│ POST /api/webhooks/composio
│ + HMAC-SHA256 signature
▼
┌─────────────────┐
│ Bytespace │ 1. Verify signature
│ Webhook │ 2. Parse V3 payload
│ Handler │ 3. Resolve user → daemon
└────────┬────────┘
│
┌────┴────────────────────────┐
│ │
▼ ▼
DAEMON ONLINE DAEMON OFFLINE
│ │
│ Hub: trigger_event │ Queue in ctx0_trigger_events
▼ │ (pending, TTL-based)
┌─────────────────┐ │
│ Daemon │ │ On reconnect:
│ │ │ Daemon polls pending events
│ Proactive │ │ Processes in order
│ Engine │◄──────────────┘
│ │
│ 1. Evaluate │
│ 2. Permission │
│ 3. Execute/Ask │
└─────────────────┘
Component Design
1. Composio Proxy Layer
Location: apps/bytespace/src/app/api/proxy/composio/[...path]/route.ts
The proxy layer is the single point of contact between daemon and Composio. It follows the exact same pattern as the LLM proxy (/api/proxy/llm/[provider]/[...path]).
// apps/bytespace/src/app/api/proxy/composio/[...path]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { authenticateProxyRequest, createProxyCorsResponse } from '@/lib/auth/proxy'; const COMPOSIO_API_BASE = 'https://backend.composio.dev/api/v3'; export async function POST( request: NextRequest, { params }: { params: { path: string[] } } ) { const bodyText = await request.text(); // 1. Authenticate: session token + device signature const auth = await authenticateProxyRequest(request, bodyText); if (!auth.success) return auth.response; // 2. Get Composio API key (encrypted on proxy, decrypted here) const composioApiKey = await getComposioApiKey(auth.session.userId); if (!composioApiKey) { return NextResponse.json( { error: 'Composio not configured' }, { status: 400 } ); } // 3. Map bot0 userId to Composio userId // bot0 userId IS the Composio userId (direct mapping) const composioUserId = auth.session.userId; // 4. Forward to Composio API const composioPath = params.path.join('/'); const composioUrl = `${COMPOSIO_API_BASE}/${composioPath}`; const response = await fetch(composioUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': composioApiKey, }, body: bodyText, }); // 5. Stream response back to daemon return new NextResponse(response.body, { status: response.status, headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json', }, }); } export async function GET( request: NextRequest, { params }: { params: { path: string[] } } ) { // Similar pattern for GET requests (tool discovery, connection status, etc.) // ... } export async function OPTIONS() { return createProxyCorsResponse(); }
Key decisions:
- bot0 userId = Composio userId — Direct 1:1 mapping. No translation table needed. When a bot0 user with ID
usr_abc123connects to GitHub, that connection is stored underuser_id: "usr_abc123"in Composio. - Single Composio API key per Bytespace instance — Stored encrypted like other API keys. All users share the same Composio project; isolation is via
user_id. - Proxy handles all Composio calls — Daemon never sees the Composio API key.
2. Daemon Composio Client
Location: packages/daemon/src/composio/client.ts
A thin HTTP client that routes all Composio operations through the Bytespace proxy.
// packages/daemon/src/composio/client.ts import { DaemonConfig } from '../config'; import { SigningProvider, createDeviceHeaders } from '../signing'; export interface ComposioClientDeps { getConfig: () => DaemonConfig; getSigningProvider: () => SigningProvider | null; } export class ProxiedComposioClient { constructor(private deps: ComposioClientDeps) {} /** * Search for available tools across Composio toolkits. * Used by the bytespace_search tool. */ async searchTools(query: string, options?: { toolkits?: string[]; limit?: number; }): Promise<ComposioToolInfo[]> { return this.request('POST', 'tools/search', { query, toolkits: options?.toolkits, limit: options?.limit ?? 20, }); } /** * Execute a Composio tool action. * Used by the bytespace_execute tool. */ async executeTool(slug: string, args: Record<string, unknown>): Promise<unknown> { return this.request('POST', 'tools/execute', { slug, arguments: args, }); } /** * List connected accounts for the current user. */ async listConnections(options?: { toolkit?: string; status?: 'ACTIVE' | 'EXPIRED' | 'INITIATED'; }): Promise<ComposioConnection[]> { const params = new URLSearchParams(); if (options?.toolkit) params.set('toolkit', options.toolkit); if (options?.status) params.set('status', options.status); return this.request('GET', `connected_accounts?${params}`); } /** * Initiate an OAuth connection for a toolkit. * Returns a redirect URL the user must visit. */ async initiateConnection(toolkit: string, options?: { authConfigId?: string; callbackUrl?: string; }): Promise<{ redirectUrl: string; connectionId: string }> { return this.request('POST', 'connected_accounts', { toolkit, auth_config_id: options?.authConfigId, callback_url: options?.callbackUrl, }); } /** * Check connection status (polling after OAuth redirect). */ async getConnectionStatus(connectionId: string): Promise<ComposioConnection> { return this.request('GET', `connected_accounts/${connectionId}`); } /** * Create a trigger instance. */ async createTrigger(slug: string, config: Record<string, unknown>): Promise<{ triggerId: string; }> { return this.request('POST', `trigger_instances/${slug}/upsert`, { trigger_config: config, }); } /** * List active trigger instances. */ async listTriggers(): Promise<ComposioTriggerInstance[]> { return this.request('GET', 'trigger_instances/active'); } /** * Enable/disable/delete a trigger. */ async manageTrigger(triggerId: string, action: 'enable' | 'disable' | 'delete'): Promise<void> { await this.request('POST', `trigger_instances/${triggerId}/${action}`); } /** * List available trigger types for a toolkit. */ async listTriggerTypes(toolkit?: string): Promise<ComposioTriggerType[]> { const params = toolkit ? `?toolkit=${toolkit}` : ''; return this.request('GET', `triggers/types${params}`); } // ─── Internal ─── private async request<T>(method: string, path: string, body?: unknown): Promise<T> { const config = this.deps.getConfig(); const signingProvider = this.deps.getSigningProvider(); const url = `${config.proxyUrl}/api/proxy/composio/${path}`; const bodyStr = body ? JSON.stringify(body) : undefined; const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.sessionToken}`, }; // Inject device signature if (signingProvider && bodyStr) { const deviceHeaders = await createDeviceHeaders(signingProvider, bodyStr); Object.assign(headers, deviceHeaders); } const response = await fetch(url, { method, headers, body: bodyStr }); if (!response.ok) { const error = await response.text(); throw new Error(`Composio proxy error (${response.status}): ${error}`); } return response.json(); } }
3. Tool Integration in Agent Loop
Three daemon tools expose Composio to the agent. These follow the existing tool pattern exactly.
Tool 1: bytespace_search — Discover Available Tools
// packages/daemon/src/tools/composio-search.ts import { Tool, ToolResult } from './types'; export function createComposioSearchTool(deps: { getClient: () => ProxiedComposioClient | null; }): Tool { return { name: 'bytespace_search', description: `Search for available API tools across 500+ connected services (Gmail, Slack, GitHub, Salesforce, etc.). Use this to discover what actions are available before executing them. Returns tool slugs, descriptions, required parameters, and connection status.`, inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Natural language search query (e.g., "send email", "create github issue", "list slack channels")', }, toolkit: { type: 'string', description: 'Filter to a specific toolkit (e.g., "gmail", "github", "slack")', }, }, required: ['query'], }, execute: async (input) => { const client = deps.getClient(); if (!client) return 'Composio is not configured. The user needs to set up Composio in settings.'; const results = await client.searchTools(input.query, { toolkits: input.toolkit ? [input.toolkit] : undefined, limit: 15, }); if (results.length === 0) { return `No tools found for "${input.query}". Try a broader search or check if the toolkit is connected.`; } const formatted = results.map(t => `- **${t.slug}** (${t.toolkit}): ${t.description}\n Connected: ${t.connected ? 'Yes' : 'No — user must connect first'}\n Params: ${t.parameters?.map(p => p.name).join(', ') || 'none'}` ).join('\n\n'); return { output: `Found ${results.length} tools:\n\n${formatted}`, metadata: { type: 'bytespace_search', resultCount: results.length, toolkits: [...new Set(results.map(t => t.toolkit))], }, } as ToolResult; }, }; }
Tool 2: bytespace_execute — Run an Action
// packages/daemon/src/tools/composio-execute.ts import { Tool, ToolResult } from './types'; export function createComposioExecuteTool(deps: { getClient: () => ProxiedComposioClient | null; }): Tool { return { name: 'bytespace_execute', description: `Execute an action on a connected service via Composio. Use bytespace_search first to discover available tools and their parameters. The user must have an active connection to the toolkit for the action to succeed. If the user is not connected, use bytespace_connect to initiate authentication.`, inputSchema: { type: 'object', properties: { slug: { type: 'string', description: 'The tool slug from bytespace_search (e.g., "GMAIL_SEND_EMAIL", "GITHUB_CREATE_ISSUE")', }, arguments: { type: 'object', description: 'Arguments for the tool as key-value pairs', additionalProperties: true, }, connected_account_id: { type: 'string', description: 'Optional: specific connected account ID if user has multiple accounts for this toolkit', }, }, required: ['slug', 'arguments'], }, execute: async (input) => { const client = deps.getClient(); if (!client) return 'Composio is not configured.'; try { const result = await client.executeTool(input.slug, { ...input.arguments, ...(input.connected_account_id && { connected_account_id: input.connected_account_id, }), }); const output = typeof result === 'string' ? result : JSON.stringify(result, null, 2); return { output: `Action ${input.slug} executed successfully:\n\n${output}`, metadata: { type: 'bytespace_execute', slug: input.slug, success: true, }, } as ToolResult; } catch (error) { const message = error instanceof Error ? error.message : String(error); // Detect auth errors and guide agent to connect if (message.includes('401') || message.includes('not connected') || message.includes('no active connection')) { const toolkit = input.slug.split('_')[0].toLowerCase(); return `The user is not connected to ${toolkit}. Use bytespace_connect to initiate authentication before retrying this action.`; } return `Action ${input.slug} failed: ${message}`; } }, }; }
Tool 3: bytespace_connect — Initiate Service Authentication
// packages/daemon/src/tools/composio-connect.ts import { Tool, ToolResult } from './types'; export function createComposioConnectTool(deps: { getClient: () => ProxiedComposioClient | null; }): Tool { return { name: 'bytespace_connect', description: `Connect the user to an external service (Gmail, GitHub, Slack, etc.) via OAuth or API key. Returns an authentication URL that the user must visit to authorize access. Use this when: - The user explicitly asks to connect a service - A bytespace_execute call fails because the user is not connected - You need to check which services are already connected Actions: "connect" (initiate OAuth), "list" (show connections), "disconnect" (remove connection)`, inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['connect', 'list', 'disconnect'], description: 'The action to perform', }, toolkit: { type: 'string', description: 'The service to connect (e.g., "gmail", "github", "slack", "notion")', }, connected_account_id: { type: 'string', description: 'For disconnect: the connected account ID to remove', }, }, required: ['action'], }, execute: async (input) => { const client = deps.getClient(); if (!client) return 'Composio is not configured.'; switch (input.action) { case 'connect': { if (!input.toolkit) return 'Please specify a toolkit to connect (e.g., "gmail", "github").'; const result = await client.initiateConnection(input.toolkit); return { output: `To connect ${input.toolkit}, the user needs to authenticate:\n\n**Auth URL:** ${result.redirectUrl}\n\nPlease open this URL to authorize bot0 to access your ${input.toolkit} account. The connection will be saved automatically.`, metadata: { type: 'bytespace_connect', action: 'connect', toolkit: input.toolkit, authUrl: result.redirectUrl, connectionId: result.connectionId, }, } as ToolResult; } case 'list': { const connections = await client.listConnections({ toolkit: input.toolkit, status: 'ACTIVE', }); if (connections.length === 0) { return input.toolkit ? `No active connections to ${input.toolkit}. Use bytespace_connect with action "connect" to set one up.` : 'No active connections. The user has not connected any external services yet.'; } const formatted = connections.map(c => `- **${c.toolkit}** (${c.id}): ${c.status} — connected ${c.createdAt}` ).join('\n'); return { output: `Active connections:\n\n${formatted}`, metadata: { type: 'bytespace_connect', action: 'list', connectionCount: connections.length, }, } as ToolResult; } case 'disconnect': { if (!input.connected_account_id) return 'Please specify connected_account_id to disconnect.'; // Composio API: DELETE connected account return `Connection ${input.connected_account_id} has been disconnected.`; } } }, }; }
Tool 4: bytespace_trigger — Manage Event Triggers
// packages/daemon/src/tools/composio-trigger.ts import { Tool, ToolResult } from './types'; export function createComposioTriggerTool(deps: { getClient: () => ProxiedComposioClient | null; }): Tool { return { name: 'bytespace_trigger', description: `Manage event triggers for connected services. Triggers notify the daemon when something happens (new email, GitHub PR, Slack message, etc.). This is how the agent becomes proactive — reacting to real-world events. Actions: - "create": Set up a new trigger (e.g., "notify me when I get a GitHub PR review") - "list": Show active triggers - "list_types": Show available trigger types for a toolkit - "disable" / "enable" / "delete": Manage existing triggers`, inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['create', 'list', 'list_types', 'enable', 'disable', 'delete'], }, trigger_slug: { type: 'string', description: 'Trigger type slug (e.g., "GMAIL_NEW_GMAIL_MESSAGE", "GITHUB_COMMIT_EVENT")', }, trigger_config: { type: 'object', description: 'Configuration for the trigger (e.g., { "owner": "myorg", "repo": "myrepo" })', additionalProperties: true, }, trigger_id: { type: 'string', description: 'For enable/disable/delete: the trigger instance ID', }, toolkit: { type: 'string', description: 'For list_types: filter by toolkit', }, }, required: ['action'], }, execute: async (input) => { const client = deps.getClient(); if (!client) return 'Composio is not configured.'; switch (input.action) { case 'create': { if (!input.trigger_slug) return 'Specify trigger_slug (e.g., "GMAIL_NEW_GMAIL_MESSAGE").'; const result = await client.createTrigger( input.trigger_slug, input.trigger_config ?? {} ); return { output: `Trigger created: ${result.triggerId}\nType: ${input.trigger_slug}\nThe daemon will now receive events when this trigger fires.`, metadata: { type: 'bytespace_trigger', action: 'create', triggerId: result.triggerId }, } as ToolResult; } case 'list': { const triggers = await client.listTriggers(); if (triggers.length === 0) return 'No active triggers.'; const formatted = triggers.map(t => `- **${t.slug}** (${t.id}): ${t.status} — ${t.toolkit}` ).join('\n'); return { output: `Active triggers:\n\n${formatted}`, metadata: { type: 'bytespace_trigger', action: 'list', count: triggers.length } } as ToolResult; } case 'list_types': { const types = await client.listTriggerTypes(input.toolkit); if (types.length === 0) return `No trigger types available${input.toolkit ? ` for ${input.toolkit}` : ''}.`; const formatted = types.map(t => `- **${t.slug}**: ${t.description}\n Config: ${JSON.stringify(t.config ?? {})}` ).join('\n\n'); return { output: `Available trigger types:\n\n${formatted}`, metadata: { type: 'bytespace_trigger', action: 'list_types' } } as ToolResult; } case 'enable': case 'disable': case 'delete': { if (!input.trigger_id) return `Specify trigger_id to ${input.action}.`; await client.manageTrigger(input.trigger_id, input.action); return `Trigger ${input.trigger_id} has been ${input.action}d.`; } } }, }; }
Tool Registration
// In packages/daemon/src/index.ts — createDefaultToolRegistry() import { createComposioSearchTool, createComposioExecuteTool, createComposioConnectTool, createComposioTriggerTool } from './tools'; import { ProxiedComposioClient } from './composio/client'; export function createDefaultToolRegistry(options?: { taskToolDeps?: TaskToolDeps; composioDeps?: { getConfig: () => DaemonConfig; getSigningProvider: () => SigningProvider | null }; }) { const registry = new ToolRegistry(); // ... existing tools ... // Composio tools (lazy client) let composioClient: ProxiedComposioClient | null = null; const getComposioClient = () => { if (!composioClient && options?.composioDeps) { composioClient = new ProxiedComposioClient(options.composioDeps); } return composioClient; }; registry.register(createComposioSearchTool({ getClient: getComposioClient })); registry.register(createComposioExecuteTool({ getClient: getComposioClient })); registry.register(createComposioConnectTool({ getClient: getComposioClient })); registry.register(createComposioTriggerTool({ getClient: getComposioClient })); return registry; }
System Prompt Addition
Add Composio awareness to the agent's system prompt in packages/daemon/src/agent/loop.ts:
// In buildSystemPrompt(), add after tool usage section: const composioPrompt = ` ## External Service Integration (Composio) You have access to 500+ external services through the bytespace_* tools: - bytespace_search: Find available tools across Gmail, Slack, GitHub, Salesforce, etc. - bytespace_execute: Run actions on connected services - bytespace_connect: Help the user connect new services (OAuth) - bytespace_trigger: Set up event triggers for proactive notifications **Workflow for service actions:** 1. If unsure what tools exist, use bytespace_search first 2. If the user isn't connected, use bytespace_connect to initiate OAuth 3. Execute actions with bytespace_execute 4. Set up triggers with bytespace_trigger for ongoing monitoring **Connection handling:** - If an action fails with "not connected", offer to connect the service - The user authenticates via a URL you provide — never ask for passwords or API keys - Connected services persist across sessions — no need to reconnect **Triggers:** - Triggers make you proactive — you'll be notified of events automatically - Always confirm with the user before creating triggers (they generate ongoing notifications) - Common triggers: new emails, GitHub PRs, Slack messages, calendar events `;
4. Auth & Connection Management
OAuth Flow (User Connects a Service)
Agent detects user needs Gmail access
│
│ bytespace_connect(action: "connect", toolkit: "gmail")
▼
┌─────────────────┐
│ Daemon Tool │ Calls proxy → Composio API
│ │ Gets redirect URL
└────────┬────────┘
│
│ Returns auth URL to agent
▼
Agent presents URL to user in chat:
"Click here to connect Gmail: https://app.composio.dev/link/abc..."
│
│ User clicks link → browser opens
▼
┌─────────────────┐
│ Composio Auth │ White-labeled OAuth screen (bot0 branding)
│ Page │ → Google OAuth consent
│ │ → Token exchange
│ │ → Store encrypted
└────────┬────────┘
│
│ Callback to: https://bytespace.bot0.dev/api/auth-apps/callback
│ ?status=success&connected_account_id=ca_xyz
▼
┌─────────────────┐
│ Bytespace │ 1. Parse callback
│ Callback Route │ 2. Notify daemon via Hub/IPC
│ │ 3. Show success page to user
└────────┬────────┘
│
│ Event: connection_complete
▼
Daemon receives notification:
"Gmail connected for user usr_abc123 (ca_xyz)"
Agent can now execute Gmail tools
Desktop UI Integration for Auth
The Desktop app handles auth URLs with a dedicated flow:
// When agent returns bytespace_connect metadata with authUrl: // Desktop intercepts and opens in system browser (not in-app) // This is critical for OAuth — Composio's auth page needs a real browser // In Terminal.tsx / MessageItem.tsx: if (metadata?.type === 'bytespace_connect' && metadata.authUrl) { // Render a clickable button instead of raw URL <ComposioAuthButton url={metadata.authUrl} toolkit={metadata.toolkit} onComplete={() => { // Optionally poll connection status }} /> }
5. Trigger System
This is the most important infrastructure piece — triggers make the agent alive.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ TRIGGER LIFECYCLE │
│ │
│ 1. REGISTRATION │
│ Agent → bytespace_trigger(create) → Proxy → Composio API │
│ Composio stores trigger + configures webhook/poll │
│ │
│ 2. EVENT OCCURS │
│ Gmail receives email / GitHub gets PR / Slack gets message │
│ │
│ 3. COMPOSIO CAPTURES │
│ Webhook: Service pushes to Composio immediately │
│ Polling: Composio polls every 60s, detects change │
│ │
│ 4. COMPOSIO DELIVERS │
│ POST /api/webhooks/composio on Bytespace │
│ Headers: webhook-signature, x-composio-webhook-version │
│ Body: V3 payload with trigger data + metadata │
│ │
│ 5. BYTESPACE ROUTES │
│ Verify signature → Parse payload → Resolve user/daemon │
│ → Hub dispatch (online) or queue (offline) │
│ │
│ 6. DAEMON PROCESSES │
│ Proactive engine evaluates significance │
│ Permission check → Execute or ask user │
│ │
└─────────────────────────────────────────────────────────────────┘
Webhook Handler
// apps/bytespace/src/app/api/webhooks/composio/route.ts import { NextRequest, NextResponse } from 'next/server'; import crypto from 'crypto'; const COMPOSIO_WEBHOOK_SECRET = process.env.COMPOSIO_WEBHOOK_SECRET!; export async function POST(request: NextRequest) { const bodyText = await request.text(); const signature = request.headers.get('webhook-signature'); const webhookVersion = request.headers.get('x-composio-webhook-version'); // 1. Verify HMAC-SHA256 signature if (!verifyWebhookSignature(bodyText, signature)) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); } // 2. Parse V3 payload const payload = JSON.parse(bodyText); const { log_id, timestamp, type: eventType, // e.g., "gmail_new_gmail_message" data: { user_id: composioUserId, // = bot0 userId trigger_id, trigger_nano_id, connection_id, ...triggerData }, } = payload; // 3. Resolve user → active daemon const daemon = await findActiveDaemonForUser(composioUserId); // 4. Create trigger event record const event = await db.insert(ctx0TriggerEvents).values({ id: generateId(), user_id: composioUserId, trigger_id: trigger_nano_id, event_type: eventType, payload: triggerData, status: daemon ? 'dispatched' : 'pending', created_at: new Date(timestamp), }); // 5. Route to daemon if (daemon) { // Daemon is online — dispatch via Hub await dispatchViaHub(daemon.device_id, { type: 'trigger_event', eventId: event.id, triggerType: eventType, triggerId: trigger_nano_id, payload: triggerData, timestamp, }); } // If offline, event stays 'pending' — daemon polls on reconnect return NextResponse.json({ received: true, log_id }); } function verifyWebhookSignature(body: string, signatureHeader: string | null): boolean { if (!signatureHeader) return false; // Format: "v1,<base64_signature>" const [version, signature] = signatureHeader.split(','); if (version !== 'v1') return false; const expected = crypto .createHmac('sha256', COMPOSIO_WEBHOOK_SECRET) .update(body) .digest('base64'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); }
Daemon-Side Trigger Processing
When a trigger event arrives at the daemon (via Hub WebSocket), it enters the proactive engine:
// packages/daemon/src/composio/trigger-handler.ts import { Bus } from '../events/bus'; import { TriggerEvent } from '../events/events'; export interface TriggerEventPayload { eventId: string; triggerType: string; // e.g., "gmail_new_gmail_message" triggerId: string; // Composio trigger nano ID payload: unknown; // Service-specific event data timestamp: string; } export class TriggerHandler { constructor( private bus: Bus, private getAgent: () => Bot0Agent, ) {} /** * Called when a trigger event arrives from the Hub. * This is the bridge between Composio triggers and the proactive engine. */ async handleTriggerEvent(event: TriggerEventPayload): Promise<void> { // 1. Publish to event bus (for UI notification) this.bus.publish(TriggerEvent, { eventId: event.eventId, triggerType: event.triggerType, triggerId: event.triggerId, timestamp: event.timestamp, }); // 2. Format trigger as a proactive task prompt const prompt = this.formatTriggerPrompt(event); // 3. Submit to proactive engine / task queue // The proactive engine evaluates significance, checks permissions, // and either executes autonomously or asks the user await this.submitToProactiveEngine({ source: 'trigger', sourceId: event.triggerId, eventId: event.eventId, prompt, priority: this.inferPriority(event.triggerType), payload: event.payload, }); } private formatTriggerPrompt(event: TriggerEventPayload): string { // Create a natural language prompt from the trigger event // This is what the agent "sees" when processing the trigger const payloadSummary = JSON.stringify(event.payload, null, 2); return `[TRIGGER EVENT: ${event.triggerType}] A trigger event fired. Here are the details: Trigger type: ${event.triggerType} Trigger ID: ${event.triggerId} Timestamp: ${event.timestamp} Event data: ${payloadSummary} Based on this event, determine the appropriate action. Consider: 1. Is this significant enough to notify the user? 2. Can you take autonomous action based on existing permissions? 3. Should you ask the user what to do?`; } private inferPriority(triggerType: string): 'critical' | 'high' | 'normal' | 'low' { // Payment/billing triggers are critical if (triggerType.includes('payment') || triggerType.includes('billing')) return 'critical'; // Direct messages are high priority if (triggerType.includes('message') || triggerType.includes('mention')) return 'high'; // Most triggers are normal return 'normal'; } }
Trigger Event Persistence & Offline Handling
When a daemon is offline, trigger events are queued in the database:
-- New table: ctx0_trigger_events CREATE TABLE ctx0_trigger_events ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES ctx0_users(id), trigger_id TEXT NOT NULL, -- Composio trigger nano ID event_type TEXT NOT NULL, -- e.g., "gmail_new_gmail_message" payload JSONB NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'pending', -- pending | dispatched | processed | failed dispatched_at TIMESTAMPTZ, processed_at TIMESTAMPTZ, error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ttl_expires_at TIMESTAMPTZ -- Auto-expire old events ); CREATE INDEX idx_trigger_events_pending ON ctx0_trigger_events(user_id, status) WHERE status = 'pending';
When the daemon reconnects, it polls for pending events:
// On daemon reconnect / startup: async function processPendingTriggerEvents( composioClient: ProxiedComposioClient, triggerHandler: TriggerHandler, userId: string ): Promise<void> { const pending = await db .select() .from(ctx0TriggerEvents) .where( and( eq(ctx0TriggerEvents.user_id, userId), eq(ctx0TriggerEvents.status, 'pending'), // Only process events less than 24h old gt(ctx0TriggerEvents.created_at, sql`NOW() - INTERVAL '24 hours'`) ) ) .orderBy(ctx0TriggerEvents.created_at); for (const event of pending) { await triggerHandler.handleTriggerEvent({ eventId: event.id, triggerType: event.event_type, triggerId: event.trigger_id, payload: event.payload, timestamp: event.created_at.toISOString(), }); // Mark as dispatched await db.update(ctx0TriggerEvents) .set({ status: 'dispatched', dispatched_at: new Date() }) .where(eq(ctx0TriggerEvents.id, event.id)); } }
6. Desktop UI Integration
Connection Status in Settings
The Settings panel gains a new "Connections" tab showing Composio-managed service connections:
// packages/desktop/src/components/ConnectionsPanel.tsx // Lists active connections with: // - Service icon + name // - Connection status (active/expired) // - Connected at timestamp // - Disconnect button // - "Connect new service" button → opens toolkit browser // The agent can also trigger connections via bytespace_connect, // which shows an inline auth button in the chat.
Trigger Management UI
// packages/desktop/src/components/TriggersPanel.tsx // Lists active triggers with: // - Trigger type + description // - Service icon // - Status (active/paused) // - Last fired timestamp // - Enable/disable toggle // - Delete button // - "Create trigger" button → opens trigger browser
Auth URL Handling
When the agent returns a bytespace_connect metadata with authUrl, the Desktop renders a clickable button:
// In MessageItem.tsx, when rendering bytespace_connect tool results: <div className="composio-auth-prompt"> <p>To connect {toolkit}, click the button below:</p> <button onClick={() => shell.openExternal(authUrl)}> Connect {toolkit} </button> <p className="text-muted"> This will open your browser for secure authentication. </p> </div>
Security Model
Principle: Composio API Key Never Touches Daemon
┌─────────────────────────────────────┐
│ DAEMON │
│ │
│ Has: session token, proxy URL │
│ Does NOT have: Composio API key │
│ │
│ All Composio calls → Proxy │
│ Device signature on every request │
└──────────────────┬──────────────────┘
│
│ HTTPS + device sig
▼
┌─────────────────────────────────────┐
│ PROXY (Bytespace) │
│ │
│ Has: Composio API key (encrypted) │
│ Validates: session + device sig │
│ Forwards: request to Composio │
│ Injects: x-api-key header │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ COMPOSIO API │
│ │
│ Validates: API key │
│ Scopes: user_id isolation │
│ Manages: OAuth tokens, credentials │
└─────────────────────────────────────┘
Attack Scenarios & Mitigations
| Attack | Mitigation |
|---|---|
| Prompt injection tries to exfiltrate OAuth tokens | Tokens stored by Composio, never in daemon context. Agent only has tool slugs. |
| Compromised daemon tries to access other users' connections | Proxy maps session token → userId. Composio scopes all operations to userId. |
| Stolen session token used from another machine | Device signature validation — token is useless without hardware key. |
| Webhook replay attack | HMAC-SHA256 signature verification + timestamp check + idempotency via log_id. |
| Agent creates unauthorized triggers | Permission middleware gates bytespace_trigger — user must approve trigger creation. |
| Malicious trigger payload injection | Webhook signature verification prevents forged payloads. Composio signs all deliveries. |
Permission Categories for Composio Tools
// In packages/daemon/src/permissions/middleware.ts function getToolPermission(toolName: string, input: any): PermissionRequirement | null { switch (toolName) { case 'bytespace_search': // Search is read-only — auto-approve in auto mode return null; case 'bytespace_execute': // Executing actions requires permission return { permission: 'composio:execute', patterns: [input.slug], metadata: { slug: input.slug, toolkit: input.slug?.split('_')[0] }, }; case 'bytespace_connect': if (input.action === 'list') return null; // Read-only return { permission: 'composio:connect', patterns: [input.toolkit], metadata: { toolkit: input.toolkit }, }; case 'bytespace_trigger': if (input.action === 'list' || input.action === 'list_types') return null; return { permission: 'composio:trigger', patterns: [input.trigger_slug ?? input.trigger_id ?? '*'], metadata: { action: input.action }, }; } }
Permission Escalation Rules
| Tool | Auto Mode | Ask Mode |
|---|---|---|
bytespace_search | Auto-approve | Auto-approve |
bytespace_execute (read actions) | Auto-approve | Ask first time, then rule |
bytespace_execute (write actions) | Ask first time | Always ask |
bytespace_execute (delete actions) | Always ask | Always ask |
bytespace_connect (list) | Auto-approve | Auto-approve |
bytespace_connect (connect/disconnect) | Always ask | Always ask |
bytespace_trigger (list/list_types) | Auto-approve | Auto-approve |
bytespace_trigger (create) | Always ask | Always ask |
bytespace_trigger (enable/disable) | Ask first time | Always ask |
bytespace_trigger (delete) | Always ask | Always ask |
The "Always ask" for connection and trigger creation is intentional — these are high-impact operations that grant ongoing access or create persistent event subscriptions.
Data Model
New Tables
-- Track Composio connections locally for offline reference -- This is a CACHE of Composio's state, not the source of truth CREATE TABLE ctx0_bytespace_connections ( id TEXT PRIMARY KEY, -- Composio connected_account nano ID (ca_xyz) user_id TEXT NOT NULL REFERENCES ctx0_users(id), toolkit TEXT NOT NULL, -- e.g., "gmail", "github" status TEXT NOT NULL DEFAULT 'active', -- active | expired | initiated display_name TEXT, -- e.g., "Work Gmail ([email protected])" auth_scheme TEXT, -- OAUTH2 | API_KEY | BASIC connected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ, synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- Last sync with Composio ); -- Trigger event queue (for offline delivery) CREATE TABLE ctx0_trigger_events ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES ctx0_users(id), trigger_id TEXT NOT NULL, event_type TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'pending', dispatched_at TIMESTAMPTZ, processed_at TIMESTAMPTZ, error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ttl_expires_at TIMESTAMPTZ ); -- Trigger subscriptions (local cache for UI display) CREATE TABLE ctx0_bytespace_triggers ( id TEXT PRIMARY KEY, -- Composio trigger nano ID user_id TEXT NOT NULL REFERENCES ctx0_users(id), slug TEXT NOT NULL, -- e.g., "GMAIL_NEW_GMAIL_MESSAGE" toolkit TEXT NOT NULL, config JSONB NOT NULL DEFAULT '{}', -- Trigger configuration status TEXT NOT NULL DEFAULT 'active', description TEXT, -- Human-readable description created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_fired_at TIMESTAMPTZ, fire_count INTEGER NOT NULL DEFAULT 0, synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );
Schema Location
Add these to packages/ctx0/src/schema/:
| File | Table |
|---|---|
composio-connections.ts | ctx0_bytespace_connections |
composio-triggers.ts | ctx0_bytespace_triggers |
trigger-events.ts | ctx0_trigger_events |
Register in ctx0Tables array in packages/ctx0/src/schema/index.ts.
White-Label Authentication
Goal
Users never see "Composio" anywhere. The auth experience is fully bot0-branded.
Three Layers of White-Labeling
Layer 1: Auth Screen Customization (Composio Dashboard)
In Composio Project Settings → Auth Screen:
- Upload bot0 logo
- Set app title to "bot0"
- This applies to all Connect Link flows
Layer 2: Custom OAuth Apps (Per Toolkit)
For major toolkits (Google, GitHub, Slack, Microsoft), register bot0's own OAuth apps:
// Create custom auth config for Gmail with bot0's OAuth credentials const gmailAuthConfig = await composio.authConfigs.create('GMAIL', { name: 'bot0 Gmail', type: 'use_custom_auth', credentials: { client_id: process.env.GOOGLE_OAUTH_CLIENT_ID, client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET, oauth_redirect_uri: 'https://bytespace.bot0.dev/api/auth-apps/callback', }, authScheme: 'OAUTH2', });
This means when a user authorizes Gmail, the Google consent screen shows "bot0" (not "Composio").
Layer 3: Custom Redirect Domain
Route the OAuth callback through bot0's domain:
// apps/bytespace/src/app/api/auth-apps/callback/route.ts // // This is a passthrough that: // 1. Receives the OAuth callback on bytespace.bot0.dev // 2. Forwards to Composio's actual callback handler // 3. Redirects user back to bot0 Desktop app export async function GET(request: NextRequest) { const params = request.nextUrl.searchParams; // Forward to Composio's callback const composioCallback = new URL('https://backend.composio.dev/api/v3/toolkits/auth/callback'); params.forEach((value, key) => composioCallback.searchParams.set(key, value)); const response = await fetch(composioCallback.toString()); // Parse result const status = params.get('status'); const connectedAccountId = params.get('connected_account_id'); // Redirect back to bot0 Desktop (deep link or success page) const redirectUrl = new URL('https://bytespace.bot0.dev/connections/success'); if (status) redirectUrl.searchParams.set('status', status); if (connectedAccountId) redirectUrl.searchParams.set('connected_account_id', connectedAccountId); return NextResponse.redirect(redirectUrl); }
Custom OAuth Apps to Register
| Service | Priority | OAuth App Location |
|---|---|---|
| Google (Gmail, Drive, Calendar) | P0 | Google Cloud Console |
| GitHub | P0 | GitHub Developer Settings |
| Slack | P0 | Slack API |
| Microsoft (Outlook, OneDrive) | P1 | Azure AD |
| Notion | P1 | Notion Integrations |
| Linear | P2 | Linear API Settings |
| Salesforce | P2 | Salesforce Connected Apps |
For other toolkits, use Composio's managed auth (users see Composio's default OAuth app, which is fine for less common integrations).
Trigger Architecture (Deep Dive)
Why Triggers Are Critical
The current bot0 proactive engine has three input sources:
- Watchers — Polling local filesystem, clipboard, etc.
- Schedules — Cron-based time triggers
- Ingestors — Webhook receivers
Composio triggers supercharge source #3. Instead of building webhook receivers for each service, bot0 gets:
- Gmail → new email trigger (polling, ~60s delay)
- GitHub → PR, issue, commit triggers (real-time webhook)
- Slack → message, mention triggers (real-time webhook)
- Google Calendar → event triggers
- Stripe → payment, subscription triggers
- HubSpot → deal, contact triggers
- And hundreds more
Trigger Registration Flow
User: "Alert me when someone stars my GitHub repo composio/bot0"
│
▼
Agent reasons:
1. Need GITHUB_STAR_EVENT trigger
2. User must be connected to GitHub
3. Config needs: owner=composio, repo=bot0
│
│ bytespace_connect(action: "list", toolkit: "github")
▼
Agent sees: GitHub connected ✓
│
│ bytespace_trigger(action: "list_types", toolkit: "github")
▼
Agent discovers GITHUB_STAR_EVENT trigger type
│
│ bytespace_trigger(action: "create",
│ trigger_slug: "GITHUB_STAR_EVENT",
│ trigger_config: { owner: "composio", repo: "bot0" })
▼
Permission prompt: "Create trigger GITHUB_STAR_EVENT for github?"
User approves ✓
│
▼
Trigger created → Composio configures GitHub webhook
│
▼
[Later: Someone stars the repo]
│
▼
GitHub → Composio → Bytespace webhook → Hub → Daemon
│
▼
Proactive engine evaluates:
"New star on composio/bot0 by user xyz"
Priority: low
Action: Notify user (non-urgent)
│
▼
Agent: "Your repo composio/bot0 just got a new star from @xyz! 🌟"
Trigger-to-Proactive Engine Bridge
The proactive engine (documented in bot0-proactive-architecture.md) has a well-defined pipeline:
Collect → Batch → Evaluate → Permission → Execute/Ask/Store → Learn
Composio triggers feed into the Collect stage:
// packages/daemon/src/proactive/ingestors/composio.ts export class ComposioTriggerIngestor implements TriggerIngestor { readonly source = 'composio'; readonly batchWindow = 30_000; // 30 seconds readonly maxBatchSize = 50; /** * Transform a raw Composio trigger event into an observation * for the proactive engine's batch queue. */ ingest(event: TriggerEventPayload): Observation { return { id: event.eventId, source: 'composio', sourceId: event.triggerId, type: event.triggerType, data: event.payload, timestamp: new Date(event.timestamp), priority: this.inferPriority(event.triggerType), }; } /** * Batching behavior: * - GitHub star events: collapse to count ("5 new stars") * - Email triggers: collapse by sender * - Slack messages: collapse by channel * - Payment events: never batch (always critical) */ batch(observations: Observation[]): BatchedObservation[] { // Group by trigger type + context const groups = groupBy(observations, o => `${o.type}:${o.sourceId}`); return Object.entries(groups).map(([key, obs]) => ({ id: generateId(), source: 'composio', count: obs.length, triggerType: obs[0].type, firstAt: obs[0].timestamp, lastAt: obs[obs.length - 1].timestamp, summary: this.summarizeBatch(obs), observations: obs, })); } }
Trigger Categories & Behavior
| Category | Examples | Delivery | Batch | Priority |
|---|---|---|---|---|
| Real-time (webhook) | GitHub PR, Slack message, Stripe payment | Instant | 30s window | High |
| Polling | Gmail new email, Google Drive changes | ~60s delay | 30s window | Normal |
| Threshold | Custom — revenue drop, churn rate | Via Composio computed triggers | No batch | Critical |
Trigger Persistence & Survival
Triggers survive daemon restarts because they're registered in Composio (cloud-side). The daemon doesn't need to re-register triggers on startup — it only needs to:
- Ensure the webhook URL is still configured (Bytespace endpoint)
- Poll for any events that arrived while offline
- Sync trigger list to local cache (
ctx0_bytespace_triggers)
Multi-Account Management
Problem
A user might have:
- Work Gmail + Personal Gmail
- Multiple GitHub accounts
- Work Slack + community Slack
Solution: Connected Account IDs
Composio already supports multiple connected accounts per toolkit per user. Each connection gets a unique connected_account_id (e.g., ca_abc123).
// When user has multiple Gmail accounts: const connections = await client.listConnections({ toolkit: 'gmail' }); // Returns: // [ // { id: 'ca_work123', toolkit: 'gmail', displayName: 'Work ([email protected])' }, // { id: 'ca_personal456', toolkit: 'gmail', displayName: 'Personal ([email protected])' }, // ] // Agent can specify which account to use: await client.executeTool('GMAIL_SEND_EMAIL', { connected_account_id: 'ca_work123', // Use work email to: '[email protected]', subject: 'Meeting follow-up', body: '...', });
Agent Behavior with Multiple Accounts
The agent learns which account to use via context:
System prompt addition:
"When the user has multiple accounts for a service, ask which one to use
if the context is ambiguous. If the context is clear (e.g., 'send from
my work email'), use the appropriate account. You can list accounts with
bytespace_connect(action: 'list', toolkit: '<toolkit>')."
Account Disambiguation in Triggers
When creating triggers, the user must specify which account:
User: "Notify me when I get important emails"
Agent: "You have two Gmail accounts connected:
1. Work ([email protected])
2. Personal ([email protected])
Which account should I monitor for important emails? Or both?"
User: "Both"
Agent: Creates two trigger instances, one per connected account
Permission System
Composio-Specific Permission Rules
The existing permission engine (auto/ask modes with custom rules) extends naturally to Composio tools:
// Permission rule examples: // "Always allow searching for tools" { toolName: 'bytespace_search', permission: 'composio:search', mode: 'auto' } // "Always allow reading Gmail (but ask for sending)" { toolName: 'bytespace_execute', permission: 'composio:execute', patterns: ['GMAIL_LIST_*', 'GMAIL_GET_*'], mode: 'auto' } // "Ask before any write operations" { toolName: 'bytespace_execute', permission: 'composio:execute', patterns: ['*_CREATE_*', '*_SEND_*', '*_DELETE_*', '*_UPDATE_*'], mode: 'ask' } // "Always ask before creating triggers" { toolName: 'bytespace_trigger', permission: 'composio:trigger', patterns: ['create'], mode: 'ask' }
Permission Learning (from Proactive Architecture)
The proactive engine's permission learning system applies to Composio actions:
1st time: Agent wants to send Slack message → Ask user
2nd time: Agent wants to send Slack message → Ask user
3rd time: Agent wants to send Slack message → Ask user
4th time: Agent wants to send Slack message → Ask user
5th time: Agent wants to send Slack message → Ask user
→ 5 consecutive approvals → confidence = 95% → Auto-approve future Slack sends
This is configured per user, per action pattern, and persists across sessions.
Implementation Plan
Phase 1: Foundation (Week 1-2)
Goal: Composio tools work in the agent loop, users can connect services and execute actions.
| Task | File | Description |
|---|---|---|
| 1.1 | packages/daemon/src/composio/client.ts | ProxiedComposioClient |
| 1.2 | packages/daemon/src/composio/types.ts | TypeScript types for Composio responses |
| 1.3 | apps/bytespace/src/app/api/proxy/composio/[...path]/route.ts | Proxy endpoint |
| 1.4 | packages/daemon/src/tools/composio-search.ts | Search tool |
| 1.5 | packages/daemon/src/tools/composio-execute.ts | Execute tool |
| 1.6 | packages/daemon/src/tools/composio-connect.ts | Connect tool |
| 1.7 | packages/daemon/src/tools/index.ts | Export new tools |
| 1.8 | packages/daemon/src/index.ts | Register tools in default registry |
| 1.9 | packages/daemon/src/permissions/middleware.ts | Permission rules for Composio tools |
| 1.10 | packages/daemon/src/agent/loop.ts | System prompt addition |
Dependencies:
@composio/coreadded to proxy's package.json (for webhook verification only)- Composio API key stored in Bytespace encrypted storage
- Composio project created with webhook URL configured
Phase 2: White-Label Auth (Week 2-3)
Goal: Users see bot0 branding throughout the OAuth flow.
| Task | Description |
|---|---|
| 2.1 | Configure Composio dashboard auth screen (logo, app name) |
| 2.2 | Register Google OAuth app under bot0's GCP project |
| 2.3 | Register GitHub OAuth app under bot0's org |
| 2.4 | Register Slack OAuth app |
| 2.5 | Create custom auth configs in Composio for each OAuth app |
| 2.6 | apps/bytespace/src/app/api/auth-apps/callback/route.ts — Custom callback handler |
| 2.7 | Test full OAuth flow: agent → auth URL → consent → callback → connected |
Phase 3: Triggers (Week 3-4)
Goal: Trigger events flow from external services to the daemon's proactive engine.
| Task | File | Description |
|---|---|---|
| 3.1 | apps/bytespace/src/app/api/webhooks/composio/route.ts | Webhook receiver |
| 3.2 | packages/ctx0/src/schema/trigger-events.ts | Event queue table |
| 3.3 | packages/ctx0/src/schema/composio-triggers.ts | Trigger cache table |
| 3.4 | packages/daemon/src/tools/composio-trigger.ts | Trigger management tool |
| 3.5 | packages/daemon/src/composio/trigger-handler.ts | Daemon-side event handler |
| 3.6 | Hub protocol: trigger_event message type | Route triggers via Hub |
| 3.7 | Daemon startup: poll pending trigger events | Offline recovery |
| 3.8 | Proactive engine: ComposioTriggerIngestor | Bridge to proactive pipeline |
Phase 4: Desktop UI (Week 4-5)
Goal: Users can manage connections and triggers from the Desktop app.
| Task | Description |
|---|---|
| 4.1 | ConnectionsPanel.tsx — List/manage connected services |
| 4.2 | TriggersPanel.tsx — List/manage active triggers |
| 4.3 | Auth URL button rendering in chat messages |
| 4.4 | Trigger event notifications in the UI |
| 4.5 | Settings panel integration (Composio section) |
Phase 5: Multi-Account & Polish (Week 5-6)
Goal: Multiple accounts per service, account disambiguation, connection health monitoring.
| Task | Description |
|---|---|
| 5.1 | Multi-account support in bytespace_connect and bytespace_execute |
| 5.2 | Account selector UI in Desktop |
| 5.3 | Connection health monitoring (detect expired connections) |
| 5.4 | Trigger event batching in proactive engine |
| 5.5 | Permission learning for Composio actions |
Open Questions
Architecture Decisions
-
Session model: Tool Router vs Direct Tools?
- Tool Router (sessions) provides meta-tools that auto-discover and execute — more autonomous but less predictable
- Direct tools (our approach above) give fine-grained control — we define the tool interface, Composio is just the backend
- Current decision: Direct tools via proxy. We control the UX.
-
MCP as alternative integration path?
- Composio offers MCP server endpoints that could be consumed by the daemon
- Pro: Standardized protocol, automatic tool discovery
- Con: Less control over tool presentation, additional hop, harder to customize permissions
- Current decision: Direct API via proxy. MCP is a future option for advanced users.
-
Composio API key: one per Bytespace instance or one per user?
- One per instance: Simpler, all users share one Composio project, isolation via
user_id - One per user: Users manage their own Composio accounts — more complex but fully self-hosted compatible
- Current decision: One per Bytespace instance. Self-hosted users can bring their own Composio API key.
- One per instance: Simpler, all users share one Composio project, isolation via
-
Trigger webhook: single endpoint or per-user?
- Single: One webhook URL for all users, route by
user_idin payload - Per-user: Individual webhook URLs (Composio MCP pattern)
- Current decision: Single endpoint. Simpler, and Composio V3 includes
user_idin payload.
- Single: One webhook URL for all users, route by
Pricing & Limits
-
Which Composio plan for bot0?
- Hobby (free) is fine for dev/testing
- Growth ($199/mo) for production with < 5,000 users
- Enterprise for scale
-
Rate limiting strategy?
- Composio: 20K-100K requests per 10 minutes (plan-dependent)
- bot0 proxy should implement its own rate limiting per user to prevent one user from exhausting the shared quota
- Proactive engine already has backpressure signals that pause triggers when queue is full
Security
-
Should self-hosted users bring their own Composio API key?
- Yes — for fully self-hosted deployments, the user registers their own Composio project
- The proxy pattern works identically; they just store their own encrypted key
- Their Composio project → their billing → their data sovereignty
-
Trigger payload sanitization?
- Composio trigger payloads are structured JSON, but could contain user-generated content (email bodies, message text)
- The proactive engine's evaluator should be aware of potential prompt injection in trigger data
- Mitigation: separate trigger data from agent instructions in the prompt template
Appendix: Composio Terminology Reference
| Composio Term | bot0 Equivalent | Description |
|---|---|---|
| User ID | bot0 userId | Unique identifier, 1:1 mapping |
| Toolkit | Service | e.g., "gmail", "github", "slack" |
| Tool | Action | e.g., "GMAIL_SEND_EMAIL" |
| Auth Config | OAuth App Config | Developer credentials + scopes |
| Connected Account | Connection | User's authenticated link to a service |
| Trigger Instance | Trigger | Active event subscription |
| Trigger Type | Trigger Template | Definition of what events to watch |
| Session | (not used directly) | We use direct tools via proxy |
| Provider | (not used directly) | We use our own tool wrappers |