feat: add skills.md endpoint and CLI run command

- Add /:username/collections/:slug/skills.md endpoint for AI-generated skills docs
- Analyze npm package source code to generate comprehensive capability contracts
- Add `tpm run` CLI command to execute tools from collections via MCP
- Cache generated skills.md in database with 1-week TTL
This commit is contained in:
Ajax Davis 2026-01-21 14:50:07 +10:00
parent c5a17a0a69
commit 9ab8946c9d
7 changed files with 1454 additions and 0 deletions

View file

@ -0,0 +1,192 @@
/**
* GET /:username/collections/:slug/skills.md
* Generate AI-powered skills documentation for a collection
* Clean URL endpoint for machine-readable skills documentation
*/
import { prisma } from '@tpmjs/db';
import type { NextRequest } from 'next/server';
import { fetchMultiplePackageSources } from '~/lib/ai/package-source-fetcher';
import { generateSkillsMarkdown } from '~/lib/ai/skills-generator';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 120; // Extended for source fetching + AI generation
// Cache for 1 week
const CACHE_DURATION_SECONDS = 7 * 24 * 60 * 60; // 604800 seconds
type RouteContext = {
params: Promise<{ username: string; slug: string }>;
};
/**
* GET /:username/collections/:slug/skills.md
* Generate skills.md markdown for a collection
*/
export async function GET(request: NextRequest, context: RouteContext) {
try {
const { username: rawUsername, slug } = await context.params;
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
// Check for cachebust query param
const cachebust = request.nextUrl.searchParams.has('cachebust');
// Find the user first
const user = await prisma.user.findUnique({
where: { username },
select: { id: true, username: true },
});
if (!user || !user.username) {
return new Response('User not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
// Find the collection by slug belonging to this user
const collection = await prisma.collection.findFirst({
where: {
slug,
userId: user.id,
},
include: {
tools: {
include: {
tool: {
select: {
id: true,
name: true,
description: true,
inputSchema: true,
package: {
select: {
npmPackageName: true,
npmVersion: true,
},
},
},
},
},
orderBy: { position: 'asc' },
take: 50, // Limit tools for performance
},
},
});
if (!collection) {
return new Response('Collection not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
// Only return if public
if (!collection.isPublic) {
return new Response('This collection is not public', {
status: 403,
headers: { 'Content-Type': 'text/plain' },
});
}
// Check cache if not busting
if (!cachebust && collection.skillsMarkdown && collection.skillsGeneratedAt) {
// Check if cache is still valid (within 1 week)
const cacheAge = Date.now() - collection.skillsGeneratedAt.getTime();
if (cacheAge < CACHE_DURATION_SECONDS * 1000) {
return new Response(collection.skillsMarkdown, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
'X-Cache': 'HIT',
'X-Cache-Age': Math.floor(cacheAge / 1000).toString(),
},
});
}
}
// Extract tools data
const tools = collection.tools.map((ct) => ({
id: ct.tool.id,
name: ct.tool.name,
description: ct.tool.description,
packageName: ct.tool.package.npmPackageName,
inputSchema: ct.tool.inputSchema,
}));
if (tools.length === 0) {
return new Response('Collection has no tools', {
status: 400,
headers: { 'Content-Type': 'text/plain' },
});
}
// Get unique packages to fetch
const uniquePackages = [
...new Map(
collection.tools.map((ct) => [
ct.tool.package.npmPackageName,
{
name: ct.tool.package.npmPackageName,
version: ct.tool.package.npmVersion,
},
])
).values(),
];
// Fetch package sources (limit to 5 packages for performance)
const packageSources = await fetchMultiplePackageSources(uniquePackages.slice(0, 5));
// Build MCP URLs
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://tpmjs.com';
const mcpUrls = {
http: `${baseUrl}/api/mcp/${username}/${slug}/http`,
sse: `${baseUrl}/api/mcp/${username}/${slug}/sse`,
};
// Generate skills markdown
const skillsMarkdown = await generateSkillsMarkdown(
{
id: collection.id,
name: collection.name,
slug: collection.slug || slug,
description: collection.description,
username: user.username,
},
tools,
packageSources,
mcpUrls
);
// Save to database for caching
await prisma.collection.update({
where: { id: collection.id },
data: {
skillsMarkdown,
skillsGeneratedAt: new Date(),
},
});
return new Response(skillsMarkdown, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
'X-Cache': 'MISS',
},
});
} catch (error) {
console.error('[Skills.md Error] GET /:username/collections/:slug/skills.md:', error);
const message =
error instanceof Error ? error.message : 'Failed to generate skills documentation';
return new Response(`Error: ${message}`, {
status: 500,
headers: { 'Content-Type': 'text/plain' },
});
}
}

View file

@ -0,0 +1,333 @@
/**
* Package Source Fetcher
* Fetches full source code for npm packages via multiple CDN sources
*/
export interface PackageFile {
path: string;
content: string;
}
export interface PackageSource {
packageName: string;
version: string;
files: PackageFile[];
packageJson: Record<string, unknown>;
}
// Maximum files to fetch per package
const MAX_FILES_PER_PACKAGE = 10;
// Maximum file size to fetch (50KB)
const MAX_FILE_SIZE = 50 * 1024;
// Timeout for individual fetches
const FETCH_TIMEOUT_MS = 10000;
/**
* Fetch with timeout
*/
async function fetchWithTimeout(
url: string,
timeoutMs: number = FETCH_TIMEOUT_MS
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'tpmjs-skills-generator/1.0',
},
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Fetch package.json from npm registry
*/
async function fetchPackageJson(
packageName: string,
version?: string
): Promise<{ packageJson: Record<string, unknown>; resolvedVersion: string }> {
const registryUrl = `https://registry.npmjs.org/${packageName}`;
const response = await fetchWithTimeout(registryUrl);
if (!response.ok) {
throw new Error(`Failed to fetch package.json for ${packageName}: ${response.status}`);
}
const data = await response.json();
const resolvedVersion = version || data['dist-tags']?.latest;
if (!resolvedVersion) {
throw new Error(`Could not resolve version for ${packageName}`);
}
const versionData = data.versions?.[resolvedVersion];
if (!versionData) {
throw new Error(`Version ${resolvedVersion} not found for ${packageName}`);
}
return { packageJson: versionData, resolvedVersion };
}
/**
* Get list of entry point files from package.json
*/
function getEntryPoints(packageJson: Record<string, unknown>): string[] {
const entryPoints: string[] = [];
// Check main entry point
if (typeof packageJson.main === 'string') {
entryPoints.push(packageJson.main);
}
// Check module entry point (ES modules)
if (typeof packageJson.module === 'string') {
entryPoints.push(packageJson.module);
}
// Check exports field (modern packages)
if (packageJson.exports) {
const exports = packageJson.exports as Record<string, unknown>;
extractExportPaths(exports, entryPoints);
}
// Check types entry point
if (typeof packageJson.types === 'string') {
entryPoints.push(packageJson.types);
}
if (typeof packageJson.typings === 'string') {
entryPoints.push(packageJson.typings);
}
// Default fallbacks if nothing found
if (entryPoints.length === 0) {
entryPoints.push('index.js', 'index.ts', 'src/index.js', 'src/index.ts');
}
// Dedupe and normalize paths
const normalized = [...new Set(entryPoints)].map((p) => (p.startsWith('./') ? p.slice(2) : p));
return normalized;
}
/**
* Recursively extract paths from exports field
*/
function extractExportPaths(exports: Record<string, unknown>, paths: string[]): void {
for (const value of Object.values(exports)) {
if (typeof value === 'string') {
paths.push(value);
} else if (typeof value === 'object' && value !== null) {
// Handle conditional exports like { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }
extractExportPaths(value as Record<string, unknown>, paths);
}
}
}
/**
* Try to fetch a file from unpkg CDN
*/
async function fetchFromUnpkg(
packageName: string,
version: string,
filePath: string
): Promise<string | null> {
const url = `https://unpkg.com/${packageName}@${version}/${filePath}`;
try {
const response = await fetchWithTimeout(url);
if (!response.ok) return null;
// Check content length before reading
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
console.warn(`File too large: ${filePath} (${contentLength} bytes)`);
return null;
}
const text = await response.text();
if (text.length > MAX_FILE_SIZE) {
return null;
}
return text;
} catch {
return null;
}
}
/**
* Try to fetch a file from esm.sh CDN (with ?raw for source)
*/
async function fetchFromEsmSh(
packageName: string,
version: string,
filePath: string
): Promise<string | null> {
// esm.sh requires different URL patterns for raw source
const url = `https://esm.sh/${packageName}@${version}/${filePath}?raw`;
try {
const response = await fetchWithTimeout(url);
if (!response.ok) return null;
const text = await response.text();
if (text.length > MAX_FILE_SIZE) {
return null;
}
return text;
} catch {
return null;
}
}
/**
* Fetch a single file from CDN (tries multiple sources)
*/
async function fetchFile(
packageName: string,
version: string,
filePath: string
): Promise<string | null> {
// Try unpkg first (more reliable for source files)
let content = await fetchFromUnpkg(packageName, version, filePath);
if (content) return content;
// Try esm.sh as fallback
content = await fetchFromEsmSh(packageName, version, filePath);
if (content) return content;
return null;
}
/**
* Resolve a relative import path to an absolute path
*/
function resolveImportPath(importPath: string, baseDir: string): string {
if (importPath.startsWith('./')) {
return baseDir ? `${baseDir}/${importPath.slice(2)}` : importPath.slice(2);
}
if (importPath.startsWith('../')) {
const parts = baseDir.split('/');
const importParts = importPath.split('/');
const upCount = importParts.filter((p) => p === '..').length;
const remaining = importParts.slice(upCount).join('/');
const baseParts = parts.slice(0, parts.length - upCount);
return baseParts.length > 0 ? `${baseParts.join('/')}/${remaining}` : remaining;
}
return importPath;
}
/**
* Add file path variations with common extensions
*/
function addPathVariations(resolvedPath: string, imports: string[]): void {
if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(resolvedPath)) {
imports.push(resolvedPath);
} else {
imports.push(`${resolvedPath}.ts`);
imports.push(`${resolvedPath}.js`);
imports.push(`${resolvedPath}/index.ts`);
imports.push(`${resolvedPath}/index.js`);
}
}
/**
* Discover additional source files from a fetched file
*/
function discoverImports(content: string, basePath: string): string[] {
const imports: string[] = [];
const baseDir = basePath.includes('/') ? basePath.substring(0, basePath.lastIndexOf('/')) : '';
// Match import/export statements for local files
const importRegex = /(?:import|export).*from\s+['"](\.[^'"]+)['"]/g;
const matches = content.matchAll(importRegex);
for (const match of matches) {
const importPath = match[1];
if (!importPath) continue;
const resolvedPath = resolveImportPath(importPath, baseDir);
addPathVariations(resolvedPath, imports);
}
return imports;
}
/**
* Fetch source files for an npm package
*/
export async function fetchPackageSource(
packageName: string,
version?: string
): Promise<PackageSource> {
// 1. Fetch package.json to get metadata and entry points
const { packageJson, resolvedVersion } = await fetchPackageJson(packageName, version);
// 2. Get entry points to fetch
const entryPoints = getEntryPoints(packageJson);
const filesToFetch = new Set<string>();
const fetchedFiles: PackageFile[] = [];
const fetchedPaths = new Set<string>();
// Add entry points to fetch queue
for (const entry of entryPoints) {
filesToFetch.add(entry);
}
// 3. Fetch files breadth-first, discovering imports
const queue = [...filesToFetch];
while (queue.length > 0 && fetchedFiles.length < MAX_FILES_PER_PACKAGE) {
const filePath = queue.shift();
if (!filePath) break;
// Skip if already fetched
if (fetchedPaths.has(filePath)) continue;
fetchedPaths.add(filePath);
// Try to fetch the file
const content = await fetchFile(packageName, resolvedVersion, filePath);
if (!content) continue;
fetchedFiles.push({ path: filePath, content });
// Discover imports and add to queue
const imports = discoverImports(content, filePath);
for (const importPath of imports) {
if (!fetchedPaths.has(importPath) && !queue.includes(importPath)) {
queue.push(importPath);
}
}
}
return {
packageName,
version: resolvedVersion,
files: fetchedFiles,
packageJson,
};
}
/**
* Fetch sources for multiple packages concurrently
*/
export async function fetchMultiplePackageSources(
packages: Array<{ name: string; version?: string }>
): Promise<PackageSource[]> {
const results = await Promise.allSettled(
packages.map(({ name, version }) => fetchPackageSource(name, version))
);
return results
.filter((r): r is PromiseFulfilledResult<PackageSource> => r.status === 'fulfilled')
.map((r) => r.value);
}

View file

@ -0,0 +1,299 @@
/**
* AI-powered skills documentation generator for tool collections
* Uses Vercel AI SDK to analyze package source code and generate comprehensive skills.md
*/
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import type { PackageSource } from './package-source-fetcher';
export interface CollectionData {
id: string;
name: string;
slug: string;
description: string | null;
username: string;
}
export interface ToolData {
id: string;
name: string;
description: string;
packageName: string;
inputSchema: unknown | null;
}
export interface McpUrls {
http: string;
sse: string;
}
const SKILLS_SYSTEM_PROMPT = `You are an expert technical writer generating machine-consumable skills documentation for AI agents.
Your task is to analyze actual source code from npm packages and generate a comprehensive skills.md document that serves as a capability contract for AI agents like Claude Code, MCP clients, and tool routers.
Guidelines:
1. **Accuracy First**: Only document capabilities you can verify from the source code
2. **Real Examples**: Generate code examples based on actual function signatures and patterns in the source
3. **Input/Output Schemas**: Extract exact TypeScript types when available
4. **Source Analysis**: Provide insights about implementation details that help agents use tools correctly
5. **Integration Focus**: Show multiple ways to integrate (ESM, NPM, CLI, REST API, MCP)
6. **Constraints**: Document what tools can and cannot do based on code analysis
Format the output as clean, well-organized Markdown with proper code blocks and syntax highlighting.`;
function buildSkillsPrompt(
collection: CollectionData,
tools: ToolData[],
packageSources: PackageSource[],
mcpUrls: McpUrls
): string {
const baseUrl = mcpUrls.http.replace(/\/api\/mcp\/.*$/, '');
const collectionUrl = `${baseUrl}/${collection.username}/collections/${collection.slug}`;
// Build package source context
const sourceContext = packageSources
.map((pkg) => {
const filesContent = pkg.files
.map((f) => `### ${f.path}\n\`\`\`typescript\n${f.content.slice(0, 5000)}\n\`\`\``)
.join('\n\n');
return `## Package: ${pkg.packageName}@${pkg.version}
**package.json exports:**
\`\`\`json
${JSON.stringify(
{
main: pkg.packageJson.main,
module: pkg.packageJson.module,
exports: pkg.packageJson.exports,
types: pkg.packageJson.types,
},
null,
2
)}
\`\`\`
**Source Files:**
${filesContent}`;
})
.join('\n\n---\n\n');
// Build tools context
const toolsContext = tools
.map(
(t) => `- **${t.name}** (${t.packageName})
Description: ${t.description}
Input Schema: ${t.inputSchema ? JSON.stringify(t.inputSchema, null, 2) : 'Not available'}`
)
.join('\n\n');
return `Generate a skills.md document for this collection.
## Collection Info
- **Name:** ${collection.name}
- **Owner:** @${collection.username}
- **Slug:** ${collection.slug}
- **Description:** ${collection.description || 'No description'}
- **Collection URL:** ${collectionUrl}
## MCP Endpoints
- **HTTP:** ${mcpUrls.http}
- **SSE:** ${mcpUrls.sse}
## Tools in Collection (${tools.length} total)
${toolsContext}
## Package Source Code
Analyze the following source code to understand tool implementations:
${sourceContext}
---
Generate the skills.md document following this EXACT structure:
# Agent Skills Declaration: ${collection.name}
> Machine-consumable capability contract for AI agents (Claude Code, MCP clients, tool routers)
## 1. Agent Identity
**Collection:** ${collection.name}
**Owner:** @${collection.username}
**Primary Role:** [Derive from tools and description]
**Tool Count:** ${tools.length} tools from ${new Set(tools.map((t) => t.packageName)).size} packages
---
## 2. Core Skills
[For each tool, include:]
### Skill: [tool.name]
**Package:** \`[package.npmPackageName]\`
**Description:** [tool.description]
**Input Schema:**
\`\`\`typescript
[Extract from source code]
\`\`\`
**Output Format:** [Analyze from source]
**Source Analysis:**
[AI-generated insights from reading actual code - what does this tool actually do internally?]
**Example Usage:**
\`\`\`typescript
[Real example based on source code patterns]
\`\`\`
---
## 3. Direct Usage (Without MCP/CLI/REST)
### ESM Import (Browser/Deno)
\`\`\`typescript
[Generate real ESM import examples using esm.sh]
\`\`\`
### NPM Import (Node.js)
\`\`\`typescript
[Generate real NPM import examples]
\`\`\`
### Multi-Tool Workflow
\`\`\`typescript
[Generate a workflow example combining multiple tools]
\`\`\`
---
## 4. Agent Integration Methods
### 4.1 CLI (\`tpm run\`)
Install CLI:
\`\`\`bash
npm install -g @tpmjs/cli
\`\`\`
Run a tool:
\`\`\`bash
tpm run --collection ${collection.username}/${collection.slug} --tool [tool-name] --args '{"key": "value"}'
\`\`\`
With environment variables:
\`\`\`bash
SOME_API_KEY=xxx tpm run --collection ${collection.username}/${collection.slug} --tool [tool-name]
\`\`\`
### 4.2 REST API (JSON-RPC 2.0)
**Endpoint:** \`POST ${mcpUrls.http}\`
\`\`\`bash
curl -X POST ${mcpUrls.http} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "[mcp-tool-name]",
"arguments": { ... }
}
}'
\`\`\`
**List available tools:**
\`\`\`bash
curl -X POST ${mcpUrls.http} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
\`\`\`
### 4.3 MCP Server
**URLs:**
- HTTP: \`${mcpUrls.http}\`
- SSE: \`${mcpUrls.sse}\`
**Claude Desktop Config (\`claude_desktop_config.json\`):**
\`\`\`json
{
"mcpServers": {
"${collection.slug}": {
"url": "${mcpUrls.sse}",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
\`\`\`
**Local MCP Server (via CLI):**
\`\`\`bash
tpm mcp serve ${collection.username}/${collection.slug}
\`\`\`
---
## 5. Constraints & Safety
**This collection does NOT:**
[Generate based on tool analysis - what are the limitations?]
**Rate Limits:**
- Free tier: 100 requests/hour
- Authenticated: 1000 requests/hour
**Authentication:**
- Public collections: No auth required for read
- Tool execution: API key required
---
## 6. Versioning
**Skills Version:** 1.0.0
**Generated:** [Current timestamp]
**Collection Updated:** [Will be filled in]
---
## 7. Canonical References
- TPMJS: https://tpmjs.com
- MCP Protocol: https://modelcontextprotocol.io
- Collection Page: ${collectionUrl}`;
}
/**
* Generate skills.md markdown for a collection
*/
export async function generateSkillsMarkdown(
collection: CollectionData,
tools: ToolData[],
packageSources: PackageSource[],
mcpUrls: McpUrls
): Promise<string> {
if (tools.length === 0) {
throw new Error('Collection must have at least one tool to generate skills documentation');
}
const prompt = buildSkillsPrompt(collection, tools, packageSources, mcpUrls);
const { text } = await generateText({
model: openai('gpt-4.1-mini'),
system: SKILLS_SYSTEM_PROMPT,
prompt,
temperature: 0.3, // Lower temperature for more factual output
});
return text;
}

287
docs/PRD-omega.md Normal file
View file

@ -0,0 +1,287 @@
# Omega PRD (Draft v0.1)
Project name: Omega
Path: `/omega`
Owner: TPMJS
Status: Draft
## 1) Executive Summary
Omega is a TPMJS side project that demonstrates what happens when a single agent can discover and execute tools from the entire TPM registry. The vision is playful and bold: "the last robot/AI you ever need" (Skynet energy, but friendly). Omega will ship as a dedicated chat experience at `/omega` and a public REST API that lets anyone start and continue conversations. The product must highlight tool discovery, selection, and execution at scale, while handling missing credentials gracefully.
## 2) Problem Statement
TPMJS has powerful registry and tool execution capabilities, but the end-user experience does not yet show how these components combine into a single, continuous, tool-driven agent. Users need a simple way to:
- Ask for outcomes, not tools
- Watch the agent discover the right tool(s)
- Provide credentials only when needed
- Continue execution without restarting
## 3) Vision
Omega is a single agent with access to the TPM registry, able to choose and run the right tools to complete tasks. Users can chat or call an API and see a transparent, step-by-step tool workflow. It is a public showcase that can become a standalone product over time.
## 4) Goals and Success Metrics
### Goals
- Showcase tool discovery and execution at scale (1M+ tools).
- Deliver a great developer experience (DX) with simple API endpoints.
- Make credential handling safe, clear, and resumable.
- Provide a polished chat UI that is fast and transparent.
### Success Metrics (Early)
- Conversations created per week
- % conversations that use at least one tool
- Median time to first tool call
- Completion rate (user gets a result without abandoning)
- Avg. tool-call count per conversation
- User feedback: "felt like a real agent"
## 5) Non-Goals (v0)
- Multi-agent orchestration
- Enterprise SSO or complex permission tiers
- Guaranteed deterministic tool selection
- Complex billing or metering
## 6) Target Users and Use Cases
### Personas
- Developers evaluating TPMJS or building on it (primary for v0)
- Power users exploring tool chains
- Curious users experimenting with AI + tools
### Example Use Cases
- "Summarize a website and turn it into a checklist"
- "Analyze a CSV and generate insights"
- "Generate a proposal and send it via email"
- "Search for a tool to do X and run it"
## 7) Product Requirements
### 7.1 Chat UI (`/omega`)
- Landing intro before first chat
- New conversation
- Message stream with agent responses
- Tool activity panel (tool name, inputs, outputs, status)
- Debug mode (raw JSON) and "view raw" toggles for tool I/O
- Shareable conversation URL
- Requires login (v0)
- Credential prompt when missing env vars
- Resume execution after credentials are provided
- Planning panel for proposed plans (only when requested or task is complex)
- Cancel running tool execution or full conversation run
- Multi-user conversations: shared links allow anyone with URL to send messages
- Show participant display names and Gravatar avatars (email-based)
- Export conversation to JSON and Markdown
- Lazy-load long outputs and message history (Virtuoso-based)
- Auto-generate conversation title after first response
- System status banner (registry + executor health)
- Empty state includes creative sample prompts
- Light/dark theme supported (use TPMJS style guide)
### 7.2 REST API
Endpoints (initial):
- `POST /api/omega/conversations`
Create a conversation. Returns conversation ID and optional title.
- `POST /api/omega/conversations/:id/messages`
Send a message and stream response (SSE).
- `GET /api/omega/conversations/:id`
Fetch conversation history.
- `POST /api/omega/conversations/:id/credentials`
Provide missing env vars and resume.
- `POST /api/omega/conversations/:id/plan`
Optional: generate plan(s) without execution.
- API access requires TPMJS API key (standard user keys).
- Shared conversations accept either logged-in session or TPMJS API key.
### 7.3 Tool Discovery and Execution
- Agent can search registry and execute tools.
- Default path: registry search + execute tools (entire registry).
- Registry execution uses TPMJS sandbox executor.
- Allow tool usage transparency in UI and API responses.
- Allow users to pin preferred tools for the agent to prioritize.
- Pinned tools are always included in the plan/tool set.
- Max tool calls per message: 200 (AI SDK `maxSteps`).
### 7.6 System Prompt and Safety Controls
- Omega ships with a comprehensive default system prompt.
- Users can extend/override with a custom prompt in Omega settings.
- Safe Mode toggle: require explicit approval before tool execution (human-in-the-loop).
### 7.4 Planning
- Agent can propose one or more plans before running tools.
- Plans show tool order, purpose, expected outputs, and confidence.
- User can select a plan or ask for another.
- Planning can be auto-run for complex tasks or when explicitly requested.
### 7.5 Credentials / Missing Env Vars
- Detect missing env vars for tools.
- Pause execution when required env vars missing.
- Provide a credential prompt in UI (inline modal).
- Resume execution after credentials are supplied.
- Allow API callers to supply env vars in requests.
- Store credentials encrypted per-user in Omega settings.
## 8) UX Flows
### Flow A: Standard Chat
1. User sends a message.
2. Agent responds, streams tokens.
3. Tool calls appear in tool panel.
4. Tool results appear inline.
5. Conversation persists.
### Flow B: Missing Credentials
1. Tool requires env vars.
2. Agent pauses and returns `MISSING_ENV_VARS`.
3. UI displays required keys and descriptions.
4. User provides keys.
5. Execution resumes from same step.
### Flow C: Planning
1. User sends a request.
2. Agent responds with one or more plans.
3. User selects a plan or asks for changes.
4. Execution starts.
### Flow D: Tool Failure + Alternatives
1. Tool fails or missing credentials.
2. Agent searches for alternative tools.
3. Agent presents options and required keys in a table.
4. User selects a tool; execution resumes.
## 9) Data Model (High-Level)
- Conversation: id, userId (optional), createdAt, updatedAt, title
- Message: id, conversationId, role, content, createdAt
- ToolRun: id, conversationId, messageId, toolId, input, output, status, duration
- Plan: id, conversationId, messageId, steps, selectedPlanId, createdAt
- CredentialRequest: id, conversationId, toolRunId, requiredKeys, status, createdAt
- CredentialStore: userId, keyName, encryptedValue, scope (global/tool)
## 10) Security and Privacy
- Encrypt stored credentials at rest.
- Never expose full secret values in UI or logs.
- Redact secrets from tool outputs when needed.
- Rate limit API endpoints to prevent abuse.
- Log rate limit events (even if limits are effectively unlimited at launch).
- Audit logs for tool calls and credential access.
## 11) Technical Foundations (Reuse Existing)
From current codebase:
- Chat + SSE (agent conversations, tool-call streaming, message persistence):
`apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts`
- Pretty URL variant for agent chat (API key scopes + owner/public checks):
`apps/web/src/app/api/[username]/agents/[agentSlug]/conversation/[conversationId]/route.ts`
- Chat UI with tool panel + streaming + Virtuoso pagination:
`apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx`
- Tool execution wrapper + AI SDK tool definition builder:
`apps/web/src/lib/ai-agent/tool-executor-agent.ts`
- Executor resolution + sandbox execution:
`apps/web/src/lib/executors/index.ts`
- Registry meta-tools for search + execute:
`packages/tools/registrySearch`, `packages/tools/registryExecute`
- Env var detection and missing var reporting:
`apps/web/src/lib/agents/env-helpers.ts`
- Env var cascade and caller-provided env support:
`apps/web/src/lib/agents/build-tools.ts`
- MCP handler with env-var validation for non-owners:
`apps/web/src/lib/mcp/handlers.ts`
- Credential encryption utilities and API key storage:
`apps/web/src/lib/crypto/api-keys.ts`, `apps/web/src/app/api/user/api-keys/route.ts`
- Existing planning tool for generatePlans:
`packages/tools/official/tool-selection-plan`
## 12) Product Decisions (Initial)
- Primary audience: developers.
- Auth: login required (v0).
- Tool access: entire registry via registry search + execute.
- Model: default `gpt-4.1-mini`; users choose provider/model if they have keys.
- Planning: only on explicit request or when task complexity is high.
- Credentials: stored per-user, scoped to Omega, managed in Omega settings.
- Safety: no additional tool restrictions beyond existing TPMJS policies.
- Sharing: anyone with conversation URL can participate.
- Retention: conversations are stored indefinitely.
- Tool output: summarized by default with "view raw" toggle.
- Cancel: users can stop a running tool or full run.
- Rate limits: system in place, effectively unlimited at launch.
- Participants: show display names + Gravatar avatars.
- API auth: requires standard TPMJS API key.
- Tool selection UX: agent-only (no manual marketplace UI for v0).
- Files: uploads deferred to a later phase.
- Streaming: stream tool outputs where supported by the AI SDK.
- UI: follow TPMJS style guide (no bespoke theme for v0).
- Conversation IDs: UUIDs.
- Credentials: per-user only (no team/workspace creds in v0).
- Conversation context: keep full history (no auto-summarization in v0).
- Compliance: no special compliance requirements at launch.
- Tool budget indicator: not required.
- Shared conversation execution: only owner-stored credentials may run tools.
- Large outputs: lazy-load via virtualization.
- Export: JSON and Markdown supported.
- Memory: per-conversation only.
- Conversation titles: auto-generated after first response (AI SDK).
- Reactions: not required for v0.
- Tool reasoning: show only when model returns reasoning tokens.
- Deletion: users cannot delete conversations in v0.
- API keys: use existing TPMJS API keys only.
- Discovery: conversations are not listed; URL-only access.
- Context control: no reset button, use new chat instead.
- Tool allowlist: no allowlist (pins only).
- Audit/log retention: store all tool I/O indefinitely.
- Shared chat auth: accept session auth or TPMJS API key.
- Landing: intro view before first chat.
- Empty state: creative sample prompts.
- Theme: light/dark via style guide.
## 13) API Response Contracts (Draft)
### Missing Env Vars
```json
{
"success": false,
"error": "Missing required environment variables",
"details": {
"code": "MISSING_ENV_VARS",
"missingVars": [
{ "name": "FIRECRAWL_API_KEY", "description": "API key for Firecrawl" }
],
"hint": "Provide these variables in the 'env' field of your request"
}
}
```
### Plan Response
```json
{
"plan": [
{ "stepNumber": 1, "toolName": "registrySearchTool", "purpose": "Find tools for web scraping" },
{ "stepNumber": 2, "toolName": "registryExecuteTool", "purpose": "Run the chosen tool" }
],
"confidence": 0.74,
"reasoning": "Found 2 relevant tools for this task"
}
```
## 14) Performance and Reliability
- SSE streams should stay responsive under load.
- Tool execution timeouts (default 5 minutes).
- Concurrency controls per conversation.
- Circuit breaker for broken tools or executors.
## 15) Observability
- Log tool calls and errors.
- Track token usage per conversation.
- Surface tool success/failure rates.
- Store conversation logs for debugging.
## 16) Risks and Mitigations
- Tool quality variance: provide confidence and fallbacks.
- Missing credentials: pause/resume flow with clear UX.
- Abuse risk: rate limiting + auth.
- Tool execution cost: usage tracking + limits.
## 17) Rollout Plan
1. Internal alpha (team-only)
2. Public beta with rate limits
3. Full public launch
## 18) Open Questions
- System prompt ownership and defaults (versioning and updates)?
- Safe Mode default on/off?
- How many plans should Omega propose by default?
- Webhook support for API events (future).
- Profiles for system prompts (future).

View file

@ -72,6 +72,24 @@ tpm collection add # Add tools to a collection
tpm collection remove # Remove tools from a collection
```
### Run Tools
Execute tools directly from collections via the MCP API:
```bash
# Basic usage
tpm run -c ajax/unsandbox -t execute --args '{"code":"print(1)","language":"python"}'
# With environment variables
tpm run -c ajax/my-collection -t myTool --env API_KEY=xxx --env DEBUG=true
# Using process environment
OPENAI_API_KEY=xxx tpm run -c ajax/ai-tools -t generate
# JSON output for scripting
tpm run -c ajax/tools -t base64Encode --args '{"data":"hello"}' --json | jq .result
```
### MCP Integration
```bash

View file

@ -0,0 +1,321 @@
import { Command, Flags } from '@oclif/core';
import { getApiKey, getApiUrl } from '../lib/config.js';
import { createOutput, type OutputFormatter } from '../lib/output.js';
interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number | null;
result?: {
content?: Array<{ type: string; text: string }>;
[key: string]: unknown;
};
error?: {
code: number;
message: string;
data?: unknown;
};
}
interface RunFlags {
collection: string;
tool: string;
args: string;
env?: string[];
json: boolean;
verbose: boolean;
timeout: number;
}
/**
* Parse and validate collection identifier
*/
function parseCollection(collection: string): { username: string; slug: string } | null {
const parts = collection.split('/');
if (parts.length !== 2) return null;
const [username, slug] = parts;
if (!username || !slug) return null;
return { username, slug };
}
/**
* Parse JSON tool arguments
*/
function parseToolArgs(argsStr: string): Record<string, unknown> | null {
try {
return JSON.parse(argsStr) as Record<string, unknown>;
} catch {
return null;
}
}
/**
* Build environment variables from process.env and explicit flags
*/
function buildEnvVars(envFlags: string[] | undefined, output: OutputFormatter): Record<string, string> {
const envVars: Record<string, string> = {};
// Add process env vars that might be relevant
for (const [key, value] of Object.entries(process.env)) {
if (value && (key.endsWith('_API_KEY') || key.endsWith('_TOKEN') || key.endsWith('_SECRET'))) {
envVars[key] = value;
}
}
// Add explicitly passed env vars (override process env)
if (envFlags) {
for (const envPair of envFlags) {
const [key, ...valueParts] = envPair.split('=');
if (key && valueParts.length > 0) {
envVars[key] = valueParts.join('=');
} else {
output.warning(`Invalid env var format: ${envPair}. Use: KEY=value`);
}
}
}
return envVars;
}
/**
* Display verbose request info
*/
function displayVerboseInfo(
output: OutputFormatter,
mcpUrl: string,
tool: string,
toolArgs: Record<string, unknown>,
envVars: Record<string, string>
): void {
output.info(`MCP Endpoint: ${mcpUrl}`);
output.info(`Tool: ${tool}`);
output.info(`Arguments: ${JSON.stringify(toolArgs, null, 2)}`);
if (Object.keys(envVars).length > 0) {
output.info(`Environment: ${Object.keys(envVars).join(', ')}`);
}
output.divider();
}
/**
* Handle HTTP error response
*/
async function handleHttpError(response: Response, output: OutputFormatter): Promise<void> {
const errorText = await response.text();
try {
const errorJson = JSON.parse(errorText) as { error?: { message?: string }; message?: string };
output.error(errorJson.error?.message || errorJson.message || `HTTP ${response.status}`);
} catch {
output.error(`HTTP ${response.status}: ${errorText}`);
}
}
/**
* Display MCP response content
*/
function displayContent(result: JsonRpcResponse['result'], output: OutputFormatter): void {
if (!result) {
output.text('(empty result)');
return;
}
if (result.content && Array.isArray(result.content)) {
for (const item of result.content) {
displayContentItem(item, output);
}
} else {
output.text(JSON.stringify(result, null, 2));
}
}
/**
* Display a single content item
*/
function displayContentItem(item: { type: string; text: string }, output: OutputFormatter): void {
if (item.type === 'text') {
try {
const parsed = JSON.parse(item.text);
output.text(JSON.stringify(parsed, null, 2));
} catch {
output.text(item.text);
}
} else if (item.type === 'image') {
output.info('[Image content]');
} else {
output.text(JSON.stringify(item, null, 2));
}
}
/**
* Handle execution error
*/
function handleExecutionError(error: unknown, flags: RunFlags, output: OutputFormatter): void {
if (error instanceof Error) {
if (error.name === 'AbortError') {
output.error(`Request timed out after ${flags.timeout} seconds`);
} else {
output.error(error.message);
}
} else {
output.error('Unknown error occurred');
}
if (flags.verbose) {
output.info(`Full error: ${String(error)}`);
}
}
export default class Run extends Command {
static description = 'Execute a tool from a collection via MCP';
static examples = [
'<%= config.bin %> run -c ajax/unsandbox -t execute --args \'{"code":"print(1)","language":"python"}\'',
'<%= config.bin %> run --collection ajax/ajax-collection --tool base64Encode --args \'{"data":"hello"}\'',
'OPENAI_API_KEY=xxx <%= config.bin %> run -c ajax/my-collection -t myTool',
'<%= config.bin %> run -c ajax/tools -t search --args \'{"query":"test"}\' --json',
'<%= config.bin %> run -c ajax/tools -t search --env API_KEY=xxx --env DEBUG=true',
];
static flags = {
collection: Flags.string({
char: 'c',
description: 'Collection identifier (username/slug)',
required: true,
}),
tool: Flags.string({
char: 't',
description: 'Tool name (MCP format: package--toolName)',
required: true,
}),
args: Flags.string({
char: 'a',
description: 'Tool arguments as JSON string',
default: '{}',
}),
env: Flags.string({
char: 'e',
description: 'Environment variables (key=value)',
multiple: true,
}),
json: Flags.boolean({
description: 'Output result as JSON',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
timeout: Flags.integer({
description: 'Request timeout in seconds',
default: 60,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Run);
const output = createOutput(flags);
// Parse and validate inputs
const collection = parseCollection(flags.collection);
if (!collection) {
output.error('Invalid collection format. Use: username/slug');
return;
}
const toolArgs = parseToolArgs(flags.args);
if (!toolArgs) {
output.error('Invalid JSON in --args flag');
return;
}
// Build request components
const envVars = buildEnvVars(flags.env, output);
const baseUrl = getApiUrl().replace(/\/api$/, '');
const mcpUrl = `${baseUrl}/api/mcp/${collection.username}/${collection.slug}/http`;
const apiKey = getApiKey();
if (!apiKey) {
output.warning('No API key configured. Some operations may fail.');
output.warning('Run `tpm auth login` to authenticate.');
}
if (flags.verbose) {
displayVerboseInfo(output, mcpUrl, flags.tool, toolArgs, envVars);
}
// Execute the request
await this.executeToolCall(flags, output, mcpUrl, apiKey, toolArgs, envVars);
}
private async executeToolCall(
flags: RunFlags,
output: OutputFormatter,
mcpUrl: string,
apiKey: string | undefined,
toolArgs: Record<string, unknown>,
envVars: Record<string, string>
): Promise<void> {
const jsonRpcRequest = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: flags.tool,
arguments: toolArgs,
...(Object.keys(envVars).length > 0 ? { env: envVars } : {}),
},
};
const spinner = output.spinner(`Executing ${flags.tool}...`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), flags.timeout * 1000);
const response = await fetch(mcpUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify(jsonRpcRequest),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
spinner.fail('Request failed');
await handleHttpError(response, output);
return;
}
const result = (await response.json()) as JsonRpcResponse;
spinner.stop();
this.handleResult(flags, output, result);
} catch (error) {
spinner.fail('Execution failed');
handleExecutionError(error, flags, output);
}
}
private handleResult(flags: RunFlags, output: OutputFormatter, result: JsonRpcResponse): void {
if (result.error) {
output.error(`Tool execution failed: ${result.error.message}`);
if (flags.verbose && result.error.data) {
output.info(`Error data: ${JSON.stringify(result.error.data, null, 2)}`);
}
return;
}
if (flags.json) {
output.json(result.result);
return;
}
output.success('Tool execution complete');
output.divider();
displayContent(result.result, output);
}
}

View file

@ -458,6 +458,10 @@ model Collection {
useCases Json? @map("use_cases") @db.JsonB
useCasesGeneratedAt DateTime? @map("use_cases_generated_at")
// AI-generated skills documentation
skillsMarkdown String? @map("skills_markdown") @db.Text
skillsGeneratedAt DateTime? @map("skills_generated_at")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")