Agent Surface

External-App Routing via Meta-Tools

Use search_tools + multi_execute to reach external apps dynamically. No pre-registration overhead.

Summary

Instead of pre-registering 500 Composio tools, expose two meta-tools: search_tools (find actions in external apps) and multi_execute_tool (run them). The agent dynamically discovers and calls external app actions without loading all tools into memory or the prompt. Trade: slightly higher latency per discovery vs. massive context savings and real-time extensibility.

  • search_tools: "I want to send an email" → returns matching actions from connected apps.
  • multi_execute_tool: Execute a discovered action with parameters.
  • Dynamic discovery: Works with any external app added post-deployment; no code changes.
  • Composio pattern: Composio uses this; proven at scale with 200+ apps.

Problem: Pre-Registration Overhead

// ❌ Pre-registering all tools
const tools = {
  composio_gmail_send_message: { ... },
  composio_gmail_list_messages: { ... },
  composio_slack_post_message: { ... },
  composio_slack_list_channels: { ... },
  composio_notion_create_page: { ... },
  composio_notion_update_page: { ... },
  // ... 200+ more ...
};

// Cost: ~10KB of tokens just listing tools.
// Only ~5% are relevant per request.

Solution: Meta-Tools for Discovery

// ✅ Two meta-tools instead of 200+
const tools = {
  search_tools: {
    description: "Search for available external app actions",
    inputSchema: z.object({
      query: z.string().describe("What do you want to do? e.g., 'send email', 'create Notion page'"),
      app: z.string().optional().describe("Filter by app name (optional)"),
      limit: z.number().optional().default(5),
    }),
  },
  multi_execute_tool: {
    description: "Execute a discovered action from an external app",
    inputSchema: z.object({
      actionId: z.string().describe("ID returned from search_tools"),
      parameters: z.record(z.any()).describe("Parameters for the action"),
    }),
  },
};

Implementation: search_tools

// mcp/tools/search-tools.ts
import { composioClient } from "@api/composio/client";

server.registerTool(
  "search_tools",
  {
    title: "Search Available Tools",
    description: "Find actions in connected external apps (Gmail, Slack, Notion, etc.)",
    inputSchema: z.object({
      query: z.string(),
      app: z.string().optional(),
      limit: z.number().optional().default(5),
    }),
    outputSchema: z.object({
      tools: z.array(z.object({
        id: z.string(),
        app: z.string(),
        name: z.string(),
        description: z.string(),
        parameters: z.record(z.any()),
      })),
    }),
  },
  async (params) => {
    // Search Composio's action catalog
    const results = await composioClient.actions.search({
      query: params.query,
      apps: params.app ? [params.app] : undefined,
      limit: params.limit,
    });

    // Filter to only connected/enabled apps for this user
    const userApps = await db.getUserConnectedApps(ctx.userId);
    const filtered = results.filter(r => userApps.includes(r.app));

    return {
      content: [{
        type: "text",
        text: `Found ${filtered.length} actions:\n${filtered.map(t => `- ${t.app}: ${t.name} - ${t.description}`).join("\n")}`,
      }],
      structuredContent: {
        tools: filtered.map(t => ({
          id: t.id,
          app: t.app,
          name: t.name,
          description: t.description,
          parameters: t.inputSchema,
        })),
      },
    };
  }
);

Example execution:

User: "Send an email to john@example.com"

Agent calls: search_tools(query="send email")
Response: [
  { id: "composio_gmail_send", app: "gmail", name: "Send Message", ... },
  { id: "composio_sendgrid_send", app: "sendgrid", name: "Send Email", ... }
]

Agent picks the best match (gmail) and calls multi_execute_tool(actionId="composio_gmail_send", ...)

Implementation: multi_execute_tool

// mcp/tools/multi-execute.ts
server.registerTool(
  "multi_execute_tool",
  {
    title: "Execute External Action",
    description: "Run a discovered action from an external app with your parameters",
    inputSchema: z.object({
      actionId: z.string().describe("Action ID from search_tools results"),
      parameters: z.record(z.any()).describe("Parameters for the action"),
    }),
    outputSchema: z.object({
      success: z.boolean(),
      result: z.record(z.any()),
      message: z.string().optional(),
    }),
  },
  async (params) => {
    try {
      // Execute via Composio
      const result = await composioClient.actions.execute({
        actionId: params.actionId,
        input: params.parameters,
        userId: ctx.userId,
      });

      // Log the action
      await db.logExternalAction(ctx.userId, params.actionId, params.parameters, result);

      return {
        content: [{
          type: "text",
          text: `Action executed successfully.\n${JSON.stringify(result, null, 2)}`,
        }],
        structuredContent: {
          success: true,
          result,
        },
      };
    } catch (err) {
      const message = err instanceof Error ? err.message : "Unknown error";
      return {
        content: [{
          type: "text",
          text: `Action failed: ${message}`,
        }],
        structuredContent: {
          success: false,
          result: {},
          message,
        },
        isError: true,
      };
    }
  }
);

System Prompt Guidance

Teach the agent how to use meta-tools:

const systemPrompt = `
## External App Workflow

You have access to external services (Gmail, Slack, Notion, Google Calendar, etc.)
that the user has connected.

**Workflow**:
1. User asks: "Send an email to Alice" or "Create a Notion page"
2. You call \`search_tools\` with the action name (e.g., "send email", "create page")
3. \`search_tools\` returns matching actions
4. You pick the best match and call \`multi_execute_tool\` with the action ID + parameters

**Example**:
- User: "Post a message in Slack"
- You: search_tools(query="post slack message")
- Response: [ {id: "composio_slack_post_message", app: "slack", ...} ]
- You: multi_execute_tool(actionId="composio_slack_post_message", parameters={channel: "#general", text: "..."})

**Rules**:
- Always call \`search_tools\` first; don't assume action IDs
- If \`search_tools\` returns no results, the app may not be connected or the action may not exist
- Include app name in the search query when possible (e.g., "send email via Gmail", not just "send email")
`;

Comparison: Pre-Registered vs. Meta-Tools

AspectPre-Registered (50+ tools)Meta-Tools
Tokens per request500–1000 (all tools listed)100 (just 2 meta-tools)
Discovery latencyNone (tools pre-loaded)+100–200ms (search query)
Adding new appsCode change + redeployWorks immediately
User clarityMany irrelevant tools visibleCurated results per query
Rate limit cost~50 token calls per request~5 token calls per request

When to use each:

  • Pre-registered: `<10` critical external apps; high discovery frequency.
  • Meta-tools: >20 external apps OR frequent app additions (Slack workspaces, Zapier integrations).

Advanced: Caching & Ranking

Use tiered caching: an LRU cache for per-user tool availability (which apps a specific user has connected -- varies per user, evicted by least-recent-use), and a short-lived TTL cache for the external app catalog (shared metadata from the provider API -- same for all users, refreshed frequently to pick up new apps):

// composio/client.ts
import { LRUCache } from "lru-cache";

// Per-user tool cache: 500 users max, 20 min TTL
const userToolsCache = new LRUCache<string, Record<string, unknown>>({
  max: 500,
  ttl: 20 * 60 * 1000,
});

// Global toolkit catalog cache: 2 min TTL (external API metadata)
const TOOLKIT_CACHE_TTL_S = 120;

For frequently-used actions, cache search results:

// mcp/tools/search-tools.ts (enhanced)
const searchCache = new Map<string, { tools: ToolResult[]; timestamp: number }>();

server.registerTool("search_tools", ..., async (params) => {
  const cacheKey = `${params.query}:${ctx.userId}`;
  
  // Check cache (valid for 1 hour)
  if (searchCache.has(cacheKey)) {
    const cached = searchCache.get(cacheKey)!;
    if (Date.now() - cached.timestamp < 60 * 60 * 1000) {
      return { content: [...], structuredContent: { tools: cached.tools } };
    }
  }

  // Search
  const results = await composioClient.actions.search({
    query: params.query,
    apps: params.app ? [params.app] : undefined,
    limit: params.limit,
  });

  // Rank by frequency (user's most-used apps appear first)
  const userAppFreq = await db.getUserAppFrequency(ctx.userId);
  const ranked = [...results].sort((a, b) => {
    const aFreq = userAppFreq[a.app] || 0;
    const bFreq = userAppFreq[b.app] || 0;
    return bFreq - aFreq;
  });

  // Cache for next request
  searchCache.set(cacheKey, { tools: ranked, timestamp: Date.now() });

  return {
    content: [...],
    structuredContent: { tools: ranked },
  };
});

Observability

Track which meta-tools are used and how often:

// Log external app actions
logger.info("[External Action]", {
  userId: ctx.userId,
  app: result.app,
  action: result.name,
  success: result.success,
  latency: result.latencyMs,
});

// Metrics
metrics.histogram("external_action_latency", result.latencyMs);
metrics.counter("external_action_executed", { app: result.app, success: result.success });

Key metrics:

  • Search-tool hit rate (how often results are used after discovery).
  • Execution success rate per app.
  • Most-used external apps.
  • Average latency of search + execute.

Integrations: Beyond Composio

If you're not using Composio, implement your own meta-tool adapter:

// mcp/tools/search-tools.ts (custom apps)
interface ExternalApp {
  id: string;
  name: string;
  actions: Array<{
    id: string;
    name: string;
    description: string;
    inputSchema: JSONSchema;
  }>;
}

const externalApps: ExternalApp[] = [
  {
    id: "stripe",
    name: "Stripe",
    actions: [
      {
        id: "stripe_create_payment",
        name: "Create Payment Intent",
        description: "Create a new payment intent",
        inputSchema: { ... },
      },
      // ...
    ],
  },
  // ... more apps ...
];

server.registerTool("search_tools", ..., async (params) => {
  const results = [];
  for (const app of externalApps) {
    for (const action of app.actions) {
      if (action.name.toLowerCase().includes(params.query.toLowerCase())) {
        results.push({ app: app.name, ...action });
      }
    }
  }
  return { ... };
});

Checklist

  • Replace pre-registered external tools with search_tools + multi_execute_tool.
  • Implement search_tools to query Composio (or custom app catalog).
  • Implement multi_execute_tool to run discovered actions.
  • Add system prompt guidance on meta-tool workflow.
  • Cache search results for performance.
  • Rank results by user frequency.
  • Monitor search + execution latency.
  • Test with real user queries (send email, create Notion page, etc.).
  • Document supported apps in onboarding flow.

See Also

On this page