Implements a complete dynamic tool loading system that allows the playground to discover and load tools from the TPMJS registry at runtime. **Architecture:** - Search tool package (@tpmjs/search-registry) - Searches registry for tools - Search API endpoint (/api/tools/search) - Text-based search with scoring - Pre-flight tool loading - Automatically searches and loads tools on every message - Railway executor service (Deno) - Loads tools from esm.sh via HTTP imports - Dynamic tool loader - Calls Railway to load and execute tools remotely **Key Components:** 1. Railway Executor (apps/railway-executor/) - Deno-based service that natively supports HTTP imports - Endpoints: /load-and-describe, /execute-tool, /cache/stats, /cache/clear - Deploys to Railway with deno run --allow-net --allow-env server.ts 2. Search Tool Package (packages/tools/search-registry/) - AI SDK v6 tool for searching TPMJS registry - Uses jsonSchema + inputSchema pattern - Searches /api/tools/search endpoint 3. Search API (apps/web/src/app/api/tools/search/) - Text-based search with composite scoring - Scores: text relevance + quality boost + download boost - Returns tool metadata with importUrl for dynamic loading 4. Dynamic Tool Loader (apps/playground/src/lib/dynamic-tool-loader.ts) - Calls Railway service to load tools from esm.sh - Creates tool wrappers that execute remotely - Process-level module cache + per-conversation tracking 5. Pre-flight Loading (apps/playground/src/app/api/chat/route.ts) - Automatically searches for tools on every user message - Loads top 5 matching tools before agent processes request - Merges with static tools for seamless experience **Technical Decisions:** - Deno over Node.js: Native HTTP import support without flags - Remote execution: Tools run in Railway sandbox, not Vercel - Pre-flight loading: Better UX than two-turn search pattern - Text search: BM25 had dependency issues, simple scoring works well **Environment Variables:** - RAILWAY_SERVICE_URL: https://endearing-commitment-production.up.railway.app 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
376 lines
9.1 KiB
Markdown
376 lines
9.1 KiB
Markdown
# Railway Service - Dynamic Tool Loader Implementation
|
|
|
|
## Overview
|
|
|
|
This document describes the Railway service implementation needed to support dynamic tool loading from esm.sh in the TPMJS playground.
|
|
|
|
## Why Railway Service?
|
|
|
|
Next.js/Turbopack intercepts all `import()` calls and tries to resolve them through its module graph. HTTP URLs like `https://esm.sh/...` are not supported.
|
|
|
|
**Solution**: Use a plain Node.js service on Railway that:
|
|
- Runs with `--experimental-network-imports` flag
|
|
- Can dynamically import from HTTP URLs (esm.sh)
|
|
- Executes tool functions and returns results
|
|
- Is already set up for existing ToolPlayground
|
|
|
|
## New Endpoint Required
|
|
|
|
### `POST /load-and-describe`
|
|
|
|
**Purpose**: Dynamically import a tool package and return its AI SDK tool definition (description, schema) without executing it.
|
|
|
|
**Request**:
|
|
```json
|
|
{
|
|
"packageName": "firecrawl-aisdk",
|
|
"exportName": "webSearchTool",
|
|
"version": "0.7.2",
|
|
"importUrl": "https://esm.sh/firecrawl-aisdk@0.7.2"
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"tool": {
|
|
"exportName": "webSearchTool",
|
|
"description": "Search the web using Firecrawl",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": { "type": "string", "description": "Search query" }
|
|
},
|
|
"required": ["query"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Implementation** (pseudo-code for Railway service):
|
|
|
|
```javascript
|
|
// server.js (Railway service)
|
|
import express from 'express';
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
// Cache for imported modules
|
|
const moduleCache = new Map();
|
|
|
|
app.post('/load-and-describe', async (req, res) => {
|
|
const { packageName, exportName, version, importUrl } = req.body;
|
|
|
|
const cacheKey = `${packageName}::${exportName}`;
|
|
|
|
try {
|
|
let toolModule;
|
|
|
|
// Check cache first
|
|
if (moduleCache.has(cacheKey)) {
|
|
console.log(`✅ Cache hit: ${cacheKey}`);
|
|
toolModule = moduleCache.get(cacheKey);
|
|
} else {
|
|
// Dynamic import from esm.sh
|
|
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
|
console.log(`📦 Importing: ${url}`);
|
|
|
|
const module = await import(url);
|
|
toolModule = module[exportName];
|
|
|
|
if (!toolModule) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: `Export "${exportName}" not found in module`
|
|
});
|
|
}
|
|
|
|
// Validate it's an AI SDK tool
|
|
if (!toolModule.description || !toolModule.execute) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: `Invalid AI SDK tool structure`
|
|
});
|
|
}
|
|
|
|
// Cache it
|
|
moduleCache.set(cacheKey, toolModule);
|
|
}
|
|
|
|
// Extract tool definition (description + schema)
|
|
// AI SDK v6 tools have: description, inputSchema, execute
|
|
res.json({
|
|
success: true,
|
|
tool: {
|
|
exportName,
|
|
description: toolModule.description,
|
|
inputSchema: toolModule.inputSchema || toolModule.parameters?.shape || {},
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to load tool:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Start server
|
|
const PORT = process.env.PORT || 3000;
|
|
app.listen(PORT, () => {
|
|
console.log(`Railway tool loader running on port ${PORT}`);
|
|
});
|
|
```
|
|
|
|
**Railway Deployment**:
|
|
```bash
|
|
# Start command in Railway settings:
|
|
node --experimental-network-imports server.js
|
|
|
|
# Or in package.json:
|
|
{
|
|
"scripts": {
|
|
"start": "node --experimental-network-imports server.js"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Modified Endpoint: `POST /execute-tool`
|
|
|
|
**Purpose**: Execute a dynamically loaded tool with parameters.
|
|
|
|
**Request**:
|
|
```json
|
|
{
|
|
"packageName": "firecrawl-aisdk",
|
|
"exportName": "webSearchTool",
|
|
"version": "0.7.2",
|
|
"importUrl": "https://esm.sh/firecrawl-aisdk@0.7.2",
|
|
"params": {
|
|
"query": "latest AI news"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"output": {
|
|
"results": [...]
|
|
},
|
|
"executionTimeMs": 1234
|
|
}
|
|
```
|
|
|
|
**Implementation** (pseudo-code):
|
|
|
|
```javascript
|
|
app.post('/execute-tool', async (req, res) => {
|
|
const { packageName, exportName, version, importUrl, params } = req.body;
|
|
|
|
const cacheKey = `${packageName}::${exportName}`;
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
let toolModule;
|
|
|
|
// Check cache or import
|
|
if (moduleCache.has(cacheKey)) {
|
|
toolModule = moduleCache.get(cacheKey);
|
|
} else {
|
|
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
|
const module = await import(url);
|
|
toolModule = module[exportName];
|
|
|
|
if (!toolModule || !toolModule.execute) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Tool not found or invalid'
|
|
});
|
|
}
|
|
|
|
moduleCache.set(cacheKey, toolModule);
|
|
}
|
|
|
|
// Execute the tool
|
|
const result = await toolModule.execute(params);
|
|
|
|
res.json({
|
|
success: true,
|
|
output: result,
|
|
executionTimeMs: Date.now() - startTime
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message,
|
|
executionTimeMs: Date.now() - startTime
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
## Integration with Playground
|
|
|
|
### 1. Update `dynamic-tool-loader.ts`
|
|
|
|
Replace local dynamic imports with Railway service calls:
|
|
|
|
```typescript
|
|
// apps/playground/src/lib/dynamic-tool-loader.ts
|
|
|
|
const RAILWAY_SERVICE_URL = process.env.RAILWAY_SERVICE_URL || 'http://localhost:3001';
|
|
|
|
export async function loadToolDynamically(
|
|
packageName: string,
|
|
exportName: string,
|
|
version: string,
|
|
importUrl?: string
|
|
): Promise<any | null> {
|
|
const cacheKey = getCacheKey(packageName, exportName);
|
|
|
|
// Check local cache first
|
|
if (moduleCache.has(cacheKey)) {
|
|
console.log(`✅ Cache hit: ${cacheKey}`);
|
|
return moduleCache.get(cacheKey);
|
|
}
|
|
|
|
try {
|
|
console.log(`📦 Loading from Railway: ${packageName}/${exportName}`);
|
|
|
|
// Call Railway service to load and describe tool
|
|
const response = await fetch(`${RAILWAY_SERVICE_URL}/load-and-describe`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
packageName,
|
|
exportName,
|
|
version,
|
|
importUrl,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(`❌ Railway service error: ${response.status}`);
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
console.error(`❌ Failed to load tool: ${data.error}`);
|
|
return null;
|
|
}
|
|
|
|
// Create a tool wrapper that executes remotely
|
|
const tool = {
|
|
description: data.tool.description,
|
|
inputSchema: data.tool.inputSchema,
|
|
execute: async (params: any) => {
|
|
console.log(`🚀 Executing ${packageName}/${exportName} remotely`);
|
|
|
|
const execResponse = await fetch(`${RAILWAY_SERVICE_URL}/execute-tool`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
packageName,
|
|
exportName,
|
|
version,
|
|
importUrl,
|
|
params,
|
|
}),
|
|
});
|
|
|
|
const result = await execResponse.json();
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Tool execution failed');
|
|
}
|
|
|
|
return result.output;
|
|
},
|
|
};
|
|
|
|
// Cache the wrapper
|
|
moduleCache.set(cacheKey, tool);
|
|
console.log(`✅ Loaded and cached: ${cacheKey}`);
|
|
|
|
return tool;
|
|
} catch (error) {
|
|
console.error(`❌ Failed to load ${packageName}#${exportName}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Environment Variables
|
|
|
|
Add to `.env.local`:
|
|
```bash
|
|
RAILWAY_SERVICE_URL=https://your-railway-service.up.railway.app
|
|
```
|
|
|
|
Or for local testing with Railway running locally:
|
|
```bash
|
|
RAILWAY_SERVICE_URL=http://localhost:3001
|
|
```
|
|
|
|
## Testing Locally
|
|
|
|
### Terminal 1: Run Railway service locally
|
|
```bash
|
|
cd railway-service
|
|
node --experimental-network-imports server.js
|
|
```
|
|
|
|
### Terminal 2: Run playground
|
|
```bash
|
|
cd tpmjs
|
|
pnpm dev --filter=@tpmjs/playground
|
|
```
|
|
|
|
### Test the flow:
|
|
```bash
|
|
# Test Railway service directly
|
|
curl -X POST http://localhost:3001/load-and-describe \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"packageName": "firecrawl-aisdk",
|
|
"exportName": "webSearchTool",
|
|
"version": "0.7.2"
|
|
}'
|
|
|
|
# Then test via playground UI
|
|
# Navigate to http://localhost:3000/playground
|
|
# Ask: "search the web for latest AI news"
|
|
```
|
|
|
|
## Deployment Checklist
|
|
|
|
- [ ] Create Railway service with Node.js
|
|
- [ ] Add `--experimental-network-imports` flag to start command
|
|
- [ ] Deploy `/load-and-describe` endpoint
|
|
- [ ] Deploy `/execute-tool` endpoint (or modify existing `/execute`)
|
|
- [ ] Set `RAILWAY_SERVICE_URL` in Vercel environment variables
|
|
- [ ] Test with real tools from TPMJS registry
|
|
- [ ] Monitor Railway logs for import errors
|
|
|
|
## Benefits
|
|
|
|
1. ✅ **Works around Next.js limitations** - Imports happen in plain Node
|
|
2. ✅ **Reuses existing Railway infrastructure** - No new service needed
|
|
3. ✅ **Caching on both sides** - Local cache + Railway cache
|
|
4. ✅ **Security** - Tools execute in Railway sandbox, not Next.js
|
|
5. ✅ **Scalability** - Railway handles the heavy lifting
|
|
|
|
## Next Steps
|
|
|
|
1. Implement Railway service endpoints
|
|
2. Update `dynamic-tool-loader.ts` to use Railway
|
|
3. Test locally
|
|
4. Deploy to Railway + Vercel
|
|
5. Celebrate dynamic tool loading! 🎉
|