feat: add hot-swappable executor support for collections and agents

- Add executor configuration to Collection and Agent models in Prisma schema
- Create ExecutorConfigPanel component for selecting default or custom executors
- Add executor resolution logic with cascade (Agent → Collection → System Default)
- Create /api/executors/verify endpoint to test custom executor connectivity
- Add executor documentation page at /docs/executors with API specification
- Create deployable Vercel executor template in templates/vercel-executor/
- Update MCP handlers and agent tool execution to use configurable executors
- Add executor types and schemas to @tpmjs/types package

Users can now deploy their own executor instances and configure collections
or agents to use custom executors instead of the TPMJS default executor.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-09 00:19:22 +10:00
parent 84d894e920
commit bc36d366bc
27 changed files with 1909 additions and 85 deletions

View file

@ -0,0 +1,110 @@
# TPMJS Executor Template
Deploy your own executor for running TPMJS tools on Vercel.
## One-Click Deploy
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/tpmjs/tpmjs/tree/main/templates/vercel-executor&project-name=tpmjs-executor&repository-name=tpmjs-executor)
## What is an Executor?
An executor is a service that runs TPMJS tools. By default, TPMJS uses a shared executor, but you can deploy your own for:
- **Full control**: Run tools on your own infrastructure
- **Custom environment**: Inject your own environment variables and secrets
- **Privacy**: Keep tool execution data on your own servers
- **Performance**: Deploy in regions closest to your users
## API Endpoints
### POST /api/execute-tool
Execute a TPMJS tool.
**Request:**
```json
{
"packageName": "@tpmjs/hello",
"name": "helloWorld",
"version": "latest",
"params": { "name": "World" },
"env": { "MY_SECRET": "value" }
}
```
**Response:**
```json
{
"success": true,
"output": "Hello, World!",
"executionTimeMs": 123
}
```
### GET /api/health
Check executor health status.
**Response:**
```json
{
"status": "ok",
"version": "1.0.0",
"info": {
"runtime": "vercel-serverless",
"timestamp": "2024-01-01T00:00:00.000Z"
}
}
```
## Configuration
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `EXECUTOR_API_KEY` | No | API key for authentication. If set, requests must include `Authorization: Bearer <key>` header. |
### Setting Up API Key Authentication
1. Go to your Vercel project settings
2. Add an environment variable: `EXECUTOR_API_KEY` with a secure random value
3. When configuring your executor in TPMJS, enter this key in the "API Key" field
## How It Works
1. TPMJS sends a request to your executor with package name, tool name, and parameters
2. The executor dynamically imports the package from [esm.sh](https://esm.sh)
3. The tool's `execute()` function is called with the provided parameters
4. The result is returned to TPMJS
## Local Development
```bash
# Install dependencies
npm install
# Run development server
npm run dev
# Test the health endpoint
curl http://localhost:3000/api/health
# Test tool execution
curl -X POST http://localhost:3000/api/execute-tool \
-H "Content-Type: application/json" \
-d '{"packageName":"@anthropic-ai/tpmjs-hello","name":"helloWorld","params":{"name":"Test"}}'
```
## Security Considerations
- Always set `EXECUTOR_API_KEY` in production to prevent unauthorized access
- The executor runs tools in a serverless environment with limited capabilities
- Environment variables injected via `env` are available only during execution
- Tools are imported from esm.sh, a trusted CDN for npm packages
## Support
- [TPMJS Documentation](https://tpmjs.com/docs)
- [Executor Documentation](https://tpmjs.com/docs/executors)
- [GitHub Issues](https://github.com/tpmjs/tpmjs/issues)

View file

@ -0,0 +1,112 @@
/**
* Execute Tool Endpoint
*
* POST /api/execute-tool
* Executes a TPMJS tool with the provided parameters
*/
import { type ExecuteToolRequest, executeTool } from '@/lib/executor';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60; // 60 seconds max
/**
* Verify API key if configured
*/
function verifyApiKey(request: NextRequest): boolean {
const apiKey = process.env.EXECUTOR_API_KEY;
// If no API key is configured, allow all requests
if (!apiKey) {
return true;
}
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
return false;
}
// Expect "Bearer <api-key>" format
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || token !== apiKey) {
return false;
}
return true;
}
/**
* Validate the request body
*/
function validateRequest(body: unknown): body is ExecuteToolRequest {
if (!body || typeof body !== 'object') {
return false;
}
const req = body as Record<string, unknown>;
return (
typeof req.packageName === 'string' &&
req.packageName.length > 0 &&
typeof req.name === 'string' &&
req.name.length > 0 &&
typeof req.params === 'object' &&
req.params !== null
);
}
export async function POST(request: NextRequest): Promise<NextResponse> {
// Verify API key if configured
if (!verifyApiKey(request)) {
return NextResponse.json(
{ success: false, error: 'Unauthorized', executionTimeMs: 0 },
{ status: 401 }
);
}
try {
const body = await request.json();
// Validate request
if (!validateRequest(body)) {
return NextResponse.json(
{
success: false,
error: 'Invalid request. Required: packageName, name, params',
executionTimeMs: 0,
},
{ status: 400 }
);
}
// Execute the tool
const result = await executeTool(body);
return NextResponse.json(result, {
status: result.success ? 200 : 500,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
executionTimeMs: 0,
},
{ status: 500 }
);
}
}
// Handle OPTIONS for CORS preflight
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}

View file

@ -0,0 +1,40 @@
/**
* Health Check Endpoint
*
* GET /api/health
* Returns the health status of the executor
*/
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface HealthResponse {
status: 'ok' | 'degraded' | 'error';
version?: string;
info?: Record<string, unknown>;
}
export async function GET(): Promise<NextResponse<HealthResponse>> {
return NextResponse.json({
status: 'ok',
version: '1.0.0',
info: {
runtime: 'vercel-serverless',
timestamp: new Date().toISOString(),
},
});
}
// Handle OPTIONS for CORS preflight
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}

View file

@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View file

@ -0,0 +1,20 @@
export default function Home() {
return (
<div style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
<h1>TPMJS Executor</h1>
<p>This is a TPMJS tool executor service.</p>
<h2>Endpoints</h2>
<ul>
<li>
<code>GET /api/health</code> - Health check
</li>
<li>
<code>POST /api/execute-tool</code> - Execute a tool
</li>
</ul>
<p>
<a href="https://tpmjs.com/docs/executors">Documentation</a>
</p>
</div>
);
}

View file

@ -0,0 +1,101 @@
/**
* TPMJS Executor - Core Execution Logic
*
* This module handles dynamic import and execution of TPMJS tools.
* Tools are loaded from esm.sh and executed in a serverless environment.
*/
export interface ExecuteToolRequest {
/** NPM package name, e.g., "@tpmjs/hello" */
packageName: string;
/** Tool name within the package, e.g., "helloWorld" */
name: string;
/** Package version, e.g., "1.0.0" or "latest" */
version?: string;
/** Direct esm.sh URL override for the package */
importUrl?: string;
/** Tool parameters to pass to execute() */
params: Record<string, unknown>;
/** Environment variables to inject during execution */
env?: Record<string, string>;
}
export interface ExecuteToolResponse {
/** Whether the execution succeeded */
success: boolean;
/** Tool output on success */
output?: unknown;
/** Error message on failure */
error?: string;
/** Execution duration in milliseconds */
executionTimeMs: number;
}
/**
* Build the esm.sh URL for a package
*/
function buildEsmUrl(packageName: string, version?: string): string {
const resolvedVersion = version || 'latest';
return `https://esm.sh/${packageName}@${resolvedVersion}`;
}
/**
* Execute a TPMJS tool
*
* @param request - Execution request with package name, tool name, and parameters
* @returns Execution result
*/
export async function executeTool(request: ExecuteToolRequest): Promise<ExecuteToolResponse> {
const startTime = Date.now();
try {
// Build the import URL
const importUrl = request.importUrl || buildEsmUrl(request.packageName, request.version);
// Dynamically import the package from esm.sh
const module = await import(/* webpackIgnore: true */ importUrl);
// Get the tool export
// Try the specific tool name first, then fall back to default export
const tool = module[request.name] || module.default;
if (!tool) {
return {
success: false,
error: `Tool '${request.name}' not found in package '${request.packageName}'`,
executionTimeMs: Date.now() - startTime,
};
}
// Ensure the tool has an execute function
if (typeof tool.execute !== 'function') {
return {
success: false,
error: `Tool '${request.name}' does not have an execute() function`,
executionTimeMs: Date.now() - startTime,
};
}
// Inject environment variables if provided
if (request.env) {
for (const [key, value] of Object.entries(request.env)) {
process.env[key] = value;
}
}
// Execute the tool
const output = await tool.execute(request.params);
return {
success: true,
output,
executionTimeMs: Date.now() - startTime,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
executionTimeMs: Date.now() - startTime,
};
}
}

View file

@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable experimental features for better serverless performance
experimental: {
// Allow dynamic imports from esm.sh
serverActions: true,
},
};
module.exports = nextConfig;

View file

@ -0,0 +1,21 @@
{
"name": "tpmjs-executor",
"version": "1.0.0",
"private": true,
"description": "TPMJS Tool Executor - Deploy your own executor for running TPMJS tools",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.6.0"
}
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "nextjs",
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" }
]
}
]
}