tpmjs/docs/building-dynamic-tool-systems.md

33 KiB

Building Dynamic Tool Discovery & Injection Systems for AI Agents

A comprehensive guide to building a system where AI agents can discover, load, and execute tools dynamically at runtime. Based on the TPMJS architecture.


Table of Contents

  1. The Problem We're Solving
  2. Architecture Overview
  3. Tool Schema Design
  4. The Search System
  5. Dynamic Tool Loading
  6. Injecting Tools Into Agents
  7. Execution & Sandboxing
  8. Critical Nuances & Gotchas
  9. Database Design
  10. The Full Flow

The Problem We're Solving

Traditional AI agent setups have a fixed set of tools defined at build time:

// Static approach - tools are hardcoded
const agent = createAgent({
  tools: {
    searchWeb: webSearchTool,
    readFile: fileReadTool,
    // ... fixed list
  }
});

Problems with this approach:

  • Can't add new tools without redeploying
  • Agent has access to ALL tools even when irrelevant
  • No way for users to bring their own tools
  • Tool bloat affects context window and model performance

What we want:

  • Discover tools dynamically based on user intent
  • Load only relevant tools per conversation
  • Allow third-party tool registration
  • Execute tools securely in isolation

Architecture Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                           User Message                                   │
│                    "Help me scrape this website"                        │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                         Chat Endpoint                                    │
│  1. Extract user intent from message                                    │
│  2. Auto-search registry for relevant tools                             │
│  3. Load discovered tools dynamically                                   │
│  4. Merge with static tools                                             │
│  5. Pass combined toolset to model                                      │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                    ┌───────────────┼───────────────┐
                    ▼               ▼               ▼
            ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
            │   Search    │ │    Load     │ │   Execute   │
            │   Registry  │ │   Dynamic   │ │   Sandbox   │
            │   (BM25)    │ │   Tools     │ │   Service   │
            └─────────────┘ └─────────────┘ └─────────────┘
                    │               │               │
                    ▼               ▼               ▼
            ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
            │  Database   │ │   ESM CDN   │ │  Isolated   │
            │  (Postgres) │ │  (esm.sh)   │ │   Runtime   │
            └─────────────┘ └─────────────┘ └─────────────┘

Three-tier tool access:

  1. Static tools - Bundled with app, always available (fast, reliable)
  2. Discovered tools - Found via search, loaded on-demand
  3. Sandboxed execution - Run in isolated environment for security

Tool Schema Design

The tpmjs Field in package.json

Tools declare their capabilities via a tpmjs field in package.json:

{
  "name": "@myorg/web-scraper",
  "version": "1.0.0",
  "keywords": ["tpmjs-tool"],
  "tpmjs": {
    "category": "web-scraping",
    "frameworks": ["vercel-ai"],
    "env": [
      {
        "name": "SCRAPER_API_KEY",
        "description": "API key for the scraping service",
        "required": true
      }
    ],
    "tools": [
      {
        "exportName": "scrapeTool",
        "description": "Scrape content from any webpage and return structured data",
        "parameters": [
          {
            "name": "url",
            "type": "string",
            "description": "The URL to scrape",
            "required": true
          },
          {
            "name": "selector",
            "type": "string",
            "description": "CSS selector to extract specific elements",
            "required": false
          }
        ],
        "returns": {
          "type": "object",
          "description": "Scraped content with title, text, and metadata"
        },
        "aiAgent": {
          "useCase": "When user needs to extract content from websites",
          "limitations": "Cannot scrape JavaScript-rendered content without headless browser",
          "examples": [
            "Scrape the main content from https://example.com",
            "Extract all product prices from this page"
          ]
        }
      }
    ]
  }
}

Schema Nuances

1. Multi-tool packages

One npm package can export multiple tools. Each has its own exportName:

{
  "tpmjs": {
    "tools": [
      { "exportName": "scrapeTool", "description": "..." },
      { "exportName": "screenshotTool", "description": "..." },
      { "exportName": "pdfExtractTool", "description": "..." }
    ]
  }
}

2. The aiAgent field is critical

This isn't just documentation - it's injected into the system prompt:

// The aiAgent field helps the model understand WHEN to use this tool
aiAgent: {
  useCase: "When user needs to extract content from websites",
  limitations: "Cannot scrape JavaScript-rendered content",
  examples: ["Scrape the main content from...", "Extract all prices..."]
}

3. Tier determination is automatic

Don't make users declare "minimal" vs "rich" - compute it:

function determineTier(tpmjsField: TpmjsField): 'minimal' | 'rich' {
  const hasRichFields = tpmjsField.tools?.some(tool =>
    tool.parameters || tool.returns || tool.aiAgent
  ) || tpmjsField.env || tpmjsField.frameworks;

  return hasRichFields ? 'rich' : 'minimal';
}

4. Parameter types map to Zod schemas

function typeToZodSchema(type: string): z.ZodTypeAny {
  switch (type) {
    case 'string': return z.string();
    case 'number': return z.number();
    case 'boolean': return z.boolean();
    case 'string[]': return z.array(z.string());
    case 'object': return z.record(z.any());
    // Handle union types: "'markdown' | 'mdx'"
    default:
      if (type.includes('|')) {
        const options = type.split('|').map(s => s.trim().replace(/'/g, ''));
        return z.enum(options as [string, ...string[]]);
      }
      return z.string(); // Safe fallback
  }
}

The Search System

Why BM25?

Simple substring matching fails for tool discovery:

  • "help me with web stuff" won't match "scrape websites"
  • Need semantic relevance, not just keyword matching

BM25 (Best Matching 25) is a battle-tested ranking algorithm that considers:

  • Term frequency (TF): How often query terms appear in document
  • Inverse document frequency (IDF): Rare terms matter more than common ones
  • Document length normalization: Short docs aren't unfairly penalized

Implementation

interface SearchableDocument {
  id: string;
  content: string;  // Combined: name + description + category + aiAgent fields
  tool: Tool;
}

function bm25Search(
  query: string,
  documents: SearchableDocument[],
  recentMessages: string[] = []  // Context from conversation
): ScoredDocument[] {
  // Combine query with recent context for better relevance
  const fullQuery = [query, ...recentMessages].join(' ');
  const queryTerms = tokenize(fullQuery);

  // Calculate IDF for each term
  const idf = new Map<string, number>();
  for (const term of queryTerms) {
    const docsWithTerm = documents.filter(d =>
      tokenize(d.content).includes(term)
    ).length;
    idf.set(term, Math.log((documents.length - docsWithTerm + 0.5) / (docsWithTerm + 0.5)));
  }

  // BM25 parameters (tuned for short documents)
  const k1 = 1.2;  // Term frequency saturation
  const b = 0.75;  // Length normalization
  const avgDocLength = documents.reduce((sum, d) =>
    sum + tokenize(d.content).length, 0
  ) / documents.length;

  // Score each document
  return documents.map(doc => {
    const docTerms = tokenize(doc.content);
    const docLength = docTerms.length;

    let score = 0;
    for (const term of queryTerms) {
      const tf = docTerms.filter(t => t === term).length;
      const termIdf = idf.get(term) || 0;

      // BM25 formula
      score += termIdf * (tf * (k1 + 1)) /
        (tf + k1 * (1 - b + b * (docLength / avgDocLength)));
    }

    // Boost by quality metrics
    const qualityBoost = (doc.tool.qualityScore || 0) * 0.5;
    const downloadBoost = Math.log10((doc.tool.package.npmDownloadsLastMonth || 0) + 1) * 0.1;

    return {
      ...doc,
      score: score + qualityBoost + downloadBoost
    };
  }).sort((a, b) => b.score - a.score);
}

The recentMessages Trick

Pass recent conversation context to improve search relevance:

// In chat endpoint
const recentMessages = messages
  .filter(m => m.role === 'user')
  .slice(-3)
  .map(m => m.content);

const searchResults = await searchTool.execute({
  query: extractUserIntent(lastMessage),
  recentMessages,  // Gives search more context
  limit: 5
});

If user previously mentioned "I'm building an e-commerce site" and now says "extract prices", the search understands the context.


Dynamic Tool Loading

The Challenge

You can't just import() arbitrary npm packages at runtime in a Next.js/Vercel environment:

  • Webpack needs to know imports at build time
  • Serverless functions are stateless
  • Security concerns with arbitrary code execution

Solution: ESM CDN + Process Cache

// Process-level cache (survives across requests in same instance)
const moduleCache = new Map<string, any>();
const conversationEnv = new Map<string, Record<string, string>>();

export async function loadToolDynamically(
  packageName: string,
  exportName: string,
  version: string,
  conversationId: string,
  env?: Record<string, string>
): Promise<Tool | null> {
  const cacheKey = `${packageName}::${exportName}`;

  // Return cached tool if available
  if (moduleCache.has(cacheKey)) {
    return moduleCache.get(cacheKey);
  }

  // Store env vars for this conversation
  if (env) {
    conversationEnv.set(conversationId, {
      ...conversationEnv.get(conversationId),
      ...env
    });
  }

  try {
    // Load via external sandbox service
    const response = await fetch(`${SANDBOX_SERVICE_URL}/load-and-describe`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        packageName,
        exportName,
        version,
        importUrl: `https://esm.sh/${packageName}@${version}`,
        env: env || {},
      }),
      signal: AbortSignal.timeout(120000),
    });

    const { tool: toolMeta } = await response.json();

    // Create AI SDK tool wrapper
    const toolWrapper = tool({
      description: toolMeta.description,
      inputSchema: jsonSchema(toolMeta.inputSchema),
      execute: async (params: any) => {
        // CRITICAL: Get fresh env vars, not from closure!
        const currentEnv = conversationEnv.get(conversationId) || {};

        const execResponse = await fetch(
          `${SANDBOX_SERVICE_URL}/execute-tool`,
          {
            method: 'POST',
            body: JSON.stringify({
              packageName,
              exportName,
              version,
              params,
              env: currentEnv,  // Fresh on every execution
            }),
          }
        );

        return (await execResponse.json()).output;
      },
    });

    // Cache the wrapper (but not the env vars!)
    moduleCache.set(cacheKey, toolWrapper);
    return toolWrapper;

  } catch (error) {
    console.error(`Failed to load ${packageName}/${exportName}:`, error);
    return null;
  }
}

Why Separate Env Vars from Tool Cache?

This is a critical nuance. Consider:

// WRONG: Env vars captured in closure
const toolWrapper = tool({
  execute: async (params) => {
    // `env` is captured when tool is created
    // If user updates API key, old key is still used!
    return await execute(params, env);
  }
});
moduleCache.set(cacheKey, toolWrapper);
// RIGHT: Fresh env lookup on each execution
const toolWrapper = tool({
  execute: async (params) => {
    // Always get current env for this conversation
    const currentEnv = conversationEnv.get(conversationId) || {};
    return await execute(params, currentEnv);
  }
});

Users can update API keys mid-conversation. Cached tools must use fresh credentials.


Injecting Tools Into Agents

The Chat Endpoint Pattern

export async function POST(request: Request) {
  const { messages, conversationId, env } = await request.json();

  // 1. Store conversation-scoped env vars
  setConversationEnv(conversationId, env);

  // 2. Load static tools (always available)
  const staticTools = await loadStaticTools();

  // 3. Always include the search tool
  staticTools.searchRegistry = searchRegistryTool;

  // 4. Extract user intent for auto-discovery
  const lastUserMessage = messages.filter(m => m.role === 'user').pop();
  const userQuery = lastUserMessage?.content || '';
  const recentContext = getRecentUserMessages(messages, 3);

  // 5. Auto-search for relevant tools
  let discoveredTools: Record<string, Tool> = {};

  if (userQuery.trim().length > 0) {
    const searchResults = await searchRegistryTool.execute({
      query: userQuery,
      limit: 5,
      recentMessages: recentContext,
    });

    if (searchResults.tools?.length > 0) {
      // 6. Load discovered tools in parallel
      const loadPromises = searchResults.tools.map(meta =>
        loadToolDynamically(
          meta.packageName,
          meta.exportName,
          meta.version,
          conversationId,
          env
        )
      );

      const loadedTools = await Promise.all(loadPromises);

      // 7. Add to toolset with sanitized names
      searchResults.tools.forEach((meta, i) => {
        if (loadedTools[i]) {
          const key = sanitizeToolName(`${meta.packageName}-${meta.exportName}`);
          discoveredTools[key] = loadedTools[i];
        }
      });
    }
  }

  // 8. Merge all tools
  const allTools = {
    ...staticTools,
    ...discoveredTools,
  };

  // 9. Build system prompt with tool guidance
  const systemPrompt = buildSystemPrompt(allTools);

  // 10. Stream response
  const result = streamText({
    model: openai('gpt-4o'),
    system: systemPrompt,
    messages: convertToModelMessages(messages),
    tools: allTools,
    maxSteps: 5,  // Allow multi-step tool usage
  });

  return result.toDataStreamResponse();
}

Tool Name Sanitization

OpenAI requires tool names to match: ^[a-zA-Z0-9_-]+$

npm packages have @, /, etc. Sanitize them:

function sanitizeToolName(name: string): string {
  return name
    .replace(/@/g, '')        // Remove @
    .replace(/\//g, '_')      // Replace / with _
    .replace(/[^a-zA-Z0-9_-]/g, '_');  // Replace other special chars
}

// "@myorg/web-scraper-scrapeTool" → "myorg_web_scraper-scrapeTool"

System Prompt Engineering

The system prompt must explain the tool system:

function buildSystemPrompt(tools: Record<string, Tool>): string {
  const toolList = Object.keys(tools).join(', ');

  return `You are an AI assistant with access to tools.

## Available Tools
${toolList}

## Tool Usage Rules

1. **Execute, don't describe**: When a user asks to use a tool, CALL IT. Don't just explain what it does.

2. **searchRegistry is for DISCOVERY**: Use it when the user needs a capability you don't have loaded. Example: User asks about image processing but no image tools are loaded.

3. **Tool names are sanitized**: The tool "@myorg/scraper-scrapeTool" appears as "myorg_scraper-scrapeTool".

4. **Some tools need API keys**: If a tool fails with auth errors, ask the user to provide the required environment variable.

5. **Multi-step is allowed**: You can call multiple tools in sequence. Search → Load → Execute is a valid pattern.

## When to Search vs Execute

- User says "scrape this website" → You have scrapeTool? Execute it. Don't have it? Search first.
- User says "what tools can help with images?" → Use searchRegistry to find options.
- User says "use the hello tool" → Execute it directly if loaded.
`;
}

Execution & Sandboxing

Why Sandbox?

Executing arbitrary npm packages is dangerous:

  • Packages can access filesystem, network, env vars
  • Malicious packages could exfiltrate data
  • Even well-intentioned packages might have bugs

Sandbox Architecture

Run a separate service (Railway, Fly.io, AWS Lambda) that:

  1. Receives execution requests
  2. Loads packages in isolated environment
  3. Executes with timeout and resource limits
  4. Returns only the output
// Sandbox service (runs on Railway/Fly.io)
app.post('/execute-tool', async (req, res) => {
  const { packageName, exportName, version, params, env } = req.body;

  // Set env vars for this execution only
  const originalEnv = { ...process.env };
  Object.assign(process.env, env);

  try {
    // Dynamic import from ESM CDN
    const importUrl = `https://esm.sh/${packageName}@${version}`;
    const module = await import(importUrl);

    const tool = module[exportName] || module.default;
    if (!tool?.execute) {
      throw new Error(`No executable tool found at ${exportName}`);
    }

    // Execute with timeout
    const result = await Promise.race([
      tool.execute(params),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('Timeout')), 30000)
      )
    ]);

    res.json({ success: true, output: result });

  } catch (error) {
    res.json({ success: false, error: error.message });

  } finally {
    // Restore original env
    process.env = originalEnv;
  }
});

Health Tracking

Track tool reliability:

enum HealthStatus {
  HEALTHY = 'HEALTHY',
  BROKEN = 'BROKEN',
  UNKNOWN = 'UNKNOWN'
}

// After successful execution
if (tool.executionHealth === 'BROKEN') {
  await db.tool.update({
    where: { id: tool.id },
    data: {
      executionHealth: 'HEALTHY',
      lastHealthCheck: new Date()
    }
  });
}

// After failed execution
await db.tool.update({
  where: { id: tool.id },
  data: {
    executionHealth: 'BROKEN',
    healthCheckError: error.message,
    lastHealthCheck: new Date()
  }
});

This auto-heals false positives and surfaces genuinely broken tools.


Critical Nuances & Gotchas

1. Closure Traps with Cached Tools

// BUG: API key captured at cache time
function createCachedTool(apiKey: string) {
  const tool = {
    execute: async (params) => {
      return await callAPI(params, apiKey);  // Stale!
    }
  };
  cache.set('myTool', tool);
}

// FIX: Lookup fresh values on each execution
function createCachedTool(conversationId: string) {
  const tool = {
    execute: async (params) => {
      const apiKey = getConversationEnv(conversationId).API_KEY;
      return await callAPI(params, apiKey);  // Fresh!
    }
  };
  cache.set('myTool', tool);
}

2. Race Conditions in Parallel Loading

// BUG: Multiple requests load same tool simultaneously
const tool1 = loadTool('scraper');  // Starts loading
const tool2 = loadTool('scraper');  // Also starts loading (wasteful)

// FIX: Use loading promises as cache values
const loadingPromises = new Map<string, Promise<Tool>>();

async function loadTool(name: string): Promise<Tool> {
  if (cache.has(name)) return cache.get(name);

  if (!loadingPromises.has(name)) {
    loadingPromises.set(name, actuallyLoadTool(name).then(tool => {
      cache.set(name, tool);
      loadingPromises.delete(name);
      return tool;
    }));
  }

  return loadingPromises.get(name);
}

3. Token Budget Management

More tools = more tokens in system prompt. Be selective:

// Don't load 50 tools because they matched the search
const MAX_DYNAMIC_TOOLS = 5;

const searchResults = await search({ query, limit: MAX_DYNAMIC_TOOLS });

// Consider dropping low-relevance tools
const relevantTools = searchResults.filter(t => t.score > 0.3);

4. The "Search Loop" Problem

Model keeps searching instead of executing:

User: "Scrape example.com"
Model: Let me search for scraping tools...
Model: Found some tools! Let me search for more options...
Model: Here are some alternatives, let me search again...

Fix with explicit prompting:

The searchRegistry tool is for DISCOVERY ONLY. Once you find a relevant tool,
STOP SEARCHING and USE IT. Do not search multiple times for the same capability.

5. Error Message Quality

Bad:

{ "error": "Execution failed" }

Good:

{
  "error": "Tool execution failed",
  "details": {
    "tool": "@myorg/scraper::scrapeTool",
    "phase": "execution",
    "message": "SCRAPER_API_KEY environment variable is required",
    "suggestion": "Please provide your API key in the settings panel"
  }
}

6. Timeout Handling

Different timeouts for different phases:

// Tool loading: longer timeout (cold start, network)
const loadTimeout = 120000;  // 2 minutes

// Tool execution: depends on tool type
const defaultExecTimeout = 30000;   // 30 seconds
const longRunningTimeout = 300000;  // 5 minutes for scraping/AI tools

// Search: should be fast
const searchTimeout = 5000;  // 5 seconds

Database Design

Two-Model Approach: Package + Tool

-- Package-level metadata (npm info, category, env vars)
CREATE TABLE packages (
  id TEXT PRIMARY KEY,
  npm_package_name TEXT UNIQUE NOT NULL,
  npm_version TEXT NOT NULL,
  npm_downloads_last_month INTEGER,
  category TEXT NOT NULL,  -- 'web-scraping', 'data-processing', etc.
  env JSONB,               -- Package-level env requirements
  frameworks TEXT[],       -- ['vercel-ai', 'langchain']
  tier TEXT NOT NULL,      -- 'minimal' | 'rich'
  created_at TIMESTAMP DEFAULT NOW()
);

-- Tool-level metadata (individual exports)
CREATE TABLE tools (
  id TEXT PRIMARY KEY,
  package_id TEXT REFERENCES packages(id) ON DELETE CASCADE,
  export_name TEXT NOT NULL,
  description TEXT NOT NULL,
  parameters JSONB,
  returns JSONB,
  ai_agent JSONB,
  quality_score DECIMAL(3,2),
  import_health TEXT DEFAULT 'UNKNOWN',
  execution_health TEXT DEFAULT 'UNKNOWN',
  last_health_check TIMESTAMP,

  UNIQUE(package_id, export_name)
);

-- Indexes for search performance
CREATE INDEX idx_tools_quality ON tools(quality_score DESC);
CREATE INDEX idx_tools_health ON tools(execution_health);
CREATE INDEX idx_packages_category ON packages(category);
CREATE INDEX idx_packages_downloads ON packages(npm_downloads_last_month DESC);

Why Separate Package and Tool?

  1. One package, many tools: @myorg/utils might export 10 tools
  2. Package-level env: API keys often apply to all tools in a package
  3. Independent health: One broken tool shouldn't mark the whole package broken
  4. Efficient queries: Search tools, join package info only when needed

The Full Flow

┌──────────────────────────────────────────────────────────────────────────┐
│ 1. USER MESSAGE                                                          │
│    "Help me extract data from https://news.ycombinator.com"              │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 2. CHAT ENDPOINT RECEIVES REQUEST                                        │
│    - Extract user intent: "extract data from website"                    │
│    - Get recent context: ["I'm building a news aggregator"]              │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 3. AUTO-SEARCH REGISTRY                                                  │
│    Query: "extract data website" + context                               │
│    BM25 + quality scoring                                                │
│    Results: [scrapeTool (0.89), extractorTool (0.72), ...]              │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 4. DYNAMIC TOOL LOADING                                                  │
│    For each search result:                                               │
│    - Check cache → miss                                                  │
│    - Call sandbox: /load-and-describe                                    │
│    - Create AI SDK tool wrapper                                          │
│    - Cache wrapper (but not env vars!)                                   │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 5. MERGE TOOLS                                                           │
│    Static: { searchRegistry, helloTool }                                 │
│    Dynamic: { myorg_scraper_scrapeTool, ... }                           │
│    Combined: { searchRegistry, helloTool, myorg_scraper_scrapeTool }    │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 6. STREAM TO MODEL                                                       │
│    System: "You have these tools: ..."                                   │
│    Tools: combined toolset                                               │
│    Messages: conversation history                                        │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 7. MODEL DECIDES TO USE TOOL                                             │
│    "I'll use myorg_scraper_scrapeTool to extract the data"              │
│    Tool call: { url: "https://news.ycombinator.com" }                   │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 8. TOOL EXECUTION                                                        │
│    - Wrapper's execute() called                                          │
│    - Fresh env vars fetched for conversation                             │
│    - Request sent to sandbox service                                     │
│    - Sandbox loads package, executes, returns result                     │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 9. RESULT RETURNED TO MODEL                                              │
│    { title: "Hacker News", items: [...], metadata: {...} }              │
└──────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ 10. MODEL GENERATES RESPONSE                                             │
│     "I've extracted the data from Hacker News. Here are the top          │
│      stories: 1. ... 2. ... 3. ..."                                     │
└──────────────────────────────────────────────────────────────────────────┘

Summary

Building a dynamic tool system requires:

  1. Schema design - Rich metadata that helps both search and execution
  2. Smart search - BM25 with quality boosting, not just substring matching
  3. Careful caching - Cache tools, not credentials
  4. Sandboxed execution - Never trust arbitrary packages
  5. Health tracking - Know which tools are broken
  6. Clear prompting - Tell the model when to search vs execute

The key insight: tools are discovered at runtime based on user intent, not hardcoded at build time. This makes agents more flexible and allows a growing ecosystem of tools without redeployment.


Reference Implementation

  • Schema & validation: packages/types/src/tpmjs.ts
  • Search API: apps/web/src/app/api/tools/search/route.ts
  • Dynamic loading: apps/playground/src/lib/dynamic-tool-loader.ts
  • Chat endpoint: apps/playground/src/app/api/chat/route.ts
  • Tool execution: apps/web/src/lib/ai-agent/tool-executor-agent.ts