- Update TpmjsToolDefinitionSchema to only use 'name' field - Add 'sandbox' as valid category for sprites tools - Update all package.json files to use 'name' instead of 'exportName' - Update documentation and source files accordingly - Add 11 new sprites tools for sandbox/code-execution
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
- The Problem We're Solving
- Architecture Overview
- Tool Schema Design
- The Search System
- Dynamic Tool Loading
- Injecting Tools Into Agents
- Execution & Sandboxing
- Critical Nuances & Gotchas
- Database Design
- 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:
- Static tools - Bundled with app, always available (fast, reliable)
- Discovered tools - Found via search, loaded on-demand
- 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"],
"tpmjs": {
"category": "web-scraping",
"frameworks": ["vercel-ai"],
"env": [
{
"name": "SCRAPER_API_KEY",
"description": "API key for the scraping service",
"required": true
}
],
"tools": [
{
"name": "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 name:
{
"tpmjs": {
"tools": [
{ "name": "scrapeTool", "description": "..." },
{ "name": "screenshotTool", "description": "..." },
{ "name": "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,
name: string,
version: string,
conversationId: string,
env?: Record<string, string>
): Promise<Tool | null> {
const cacheKey = `${packageName}::${name}`;
// 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,
name,
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,
name,
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}/${name}:`, 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.name,
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.name}`);
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:
- Receives execution requests
- Loads packages in isolated environment
- Executes with timeout and resource limits
- Returns only the output
// Sandbox service (runs on Railway/Fly.io)
app.post('/execute-tool', async (req, res) => {
const { packageName, name, 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[name] || module.default;
if (!tool?.execute) {
throw new Error(`No executable tool found at ${name}`);
}
// 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?
- One package, many tools:
@myorg/utilsmight export 10 tools - Package-level env: API keys often apply to all tools in a package
- Independent health: One broken tool shouldn't mark the whole package broken
- 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:
- Schema design - Rich metadata that helps both search and execution
- Smart search - BM25 with quality boosting, not just substring matching
- Careful caching - Cache tools, not credentials
- Sandboxed execution - Never trust arbitrary packages
- Health tracking - Know which tools are broken
- 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