Compare commits

...
Sign in to create a new pull request.

41 commits

Author SHA1 Message Date
Ajax Davis
ae0d5e37cf docs: rewrite CLAUDE.md — concise, focused on production debugging
Remove verbose case studies, sync system docs, and redundant examples.
Add Vercel/Railway/GitHub CLI debugging instructions and direct DB access guide.
2026-02-10 02:47:13 +10:00
Ajax Davis
4fa8344b34 feat: replace hardcoded stats with real DB data and add view tracking
- Add PageView model for daily-bucketed view tracking
- Add viewCount fields to Tool, Collection, Agent models
- Add social proof fields to StatsSnapshot
- Add POST /api/track/view endpoint with IP-based dedup
- Add GET /api/activity/public endpoint for real activity stream
- Add /api/sync/view-rollup daily cron for aggregating views
- Expand stats-snapshot cron with new social proof queries
- Replace hardcoded homepage stats with real DB-driven props
- Add downloads to hero metrics strip
- Add PublicActivityStream component fetching real UserActivity
- Add useTrackView hook for tool, collection, agent detail pages
- Add forkCount column to collections and agents listings
- Add views/reviews to tool detail statistics sidebar
- Remove deprecated hardcoded statistics and categories from homePageData
2026-02-10 02:27:14 +10:00
Thomas Davis
66fb7ef226 fix(omega): pass tool inputSchema to LLM for proper parameter generation
The search API wasn't returning inputSchema, so dynamic tools were created
with empty schemas. The LLM saw tools with no parameters and called them
with {}. Now the search API returns inputSchema, and if it's null in the
database, we fetch it from the executor's loadAndDescribe endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 01:38:12 +10:00
Ajax Davis
5ac3beab37 fix(omega-mac): fix enter-to-send, duplicate messages, search parsing + new features
- Replace TextEditor+onKeyPress with NSTextView that properly intercepts
  Return (send) vs Shift+Return (newline)
- Only show streaming content and live tool calls while isStreaming is true,
  preventing duplicate rendering after messages are persisted
- Fix registry search JSON parsing: API returns env as [String] not objects
- Add importUrl from search API response instead of constructing it
- Add message count badge to sidebar conversation rows
- Add "Copy JSON" toolbar button (Cmd+Shift+C) to export full conversation
  with all messages and tool call results as JSON
2026-02-09 22:48:13 +10:00
Ajax Davis
894f9842d1 fix(omega-mac): support macOS 14 by replacing macOS 15-only APIs
- Add `indirect` to recursive JSONSchemaAdditional enum
- Replace `.accent` ShapeStyle (macOS 15+) with `Color.accentColor`
- Add .gitignore for .build directory
2026-02-09 22:32:37 +10:00
Ajax Davis
dda28d642c ci: add GitHub Actions workflow to build Omega Mac app
Adds a manually-triggered workflow that builds the SwiftUI app on a
macOS 15 runner with Xcode 16, packages it as a .app bundle, and
uploads it as a downloadable artifact. Also lowers the deployment
target to macOS 14 so it runs on Sonoma.
2026-02-09 22:27:21 +10:00
Thomas Davis
e42bbdff34 feat(supabase): add Supabase REST API tool package with 10 tools
Query, insert, update, delete, upsert rows, call RPC functions,
count rows, list tables, and search with ilike pattern matching
via the PostgREST API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:09:27 +10:00
Thomas Davis
1cd44b4e97 feat: add Omega Mac native macOS SwiftUI chat app
Native macOS counterpart to the web Omega agent, connecting directly
to the OpenAI API and TPMJS tool registry (1M+ AI-ready tools).

- SwiftUI app targeting macOS 15+ with dark theme
- Full agentic loop: auto-discover tools via BM25, stream OpenAI
  responses, execute tools via remote sandbox, loop up to 10x
- SwiftData persistence for conversations, messages, tool runs
- Keychain storage for API keys and environment variables
- SSE streaming via URLSession.bytes with custom parser
- Actor-based services (OpenAIService, TPMJSRegistryService)
- NavigationSplitView layout with sidebar + chat detail
- MarkdownUI for rendering assistant responses
- Settings: API key, model picker, env vars, custom system prompt
- Keyboard shortcuts: Cmd+N new chat, Cmd+, settings, Enter send

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:29:39 +10:00
Thomas Davis
c8e8a7d9b4 fix: update pnpm lockfile for new slack/discord packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:44:35 +10:00
Thomas Davis
cc69d98b6f feat: add Slack + Discord tool packages and fix 50 skipped sync packages
Add @tpmjs/tools-slack (10 tools) and @tpmjs/tools-discord (15 tools)
with full API coverage, typed outputs, and domain-validated blocks.

Add 7 missing business categories (finance, legal, hr, marketing, cx,
edu, sales) to TPMJS_CATEGORIES so 50 previously skipped packages
can sync to tpmjs.com.

Fix lefthook secrets hook to skip gracefully when git-secrets is not
installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:41:51 +10:00
Thomas Davis
7327992a9d fix(tech): auto zoom in after fitToView for readable labels
Programmatically click the zoom-in button 4 times after Isoflow renders
to increase the default zoom from ~56% to ~90-100%, making node labels
clearly readable without manual interaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:54:29 +10:00
Thomas Davis
6eb0ce7e23 fix(tech): compact Isoflow grid layout for readable labels at 92% zoom
Reduced tile grid from 14x13 to 7x7 with 2-tile spacing. This increases
the default fitToView zoom from 38% to ~92%, making all node labels
clearly readable. Removed GitHub and bridge nodes to reduce clutter,
fixed @tpmjs/npm-client naming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:43:51 +10:00
Thomas Davis
022ecda6bf fix: render Isoflow in iframe to avoid React 19 incompatibility
Isoflow bundles React 18 internally and crashes with React 19's changed
internals (ReactCurrentOwner). Solved by loading Isoflow in a standalone
HTML page via esm.sh (React 18) and embedding it as an iframe. Also
simplified the diagram to 16 key nodes for better readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:17:21 +10:00
Thomas Davis
7dca00b5da feat: add /tech page with Isoflow isometric architecture diagram
Interactive isometric visualization of the TPMJS ecosystem showing external
services, applications, published packages, internal packages, and official
tools with their interconnections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:00:45 +10:00
Thomas Davis
2a2f0f487d fix(railway): remove startCommand and invalid maxRetries from railway.toml
startCommand overrides Dockerfile CMD causing build failure. Removed
restartPolicyMaxRetries=-1 which is not a valid Railway value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 04:55:04 +10:00
Thomas Davis
198f9f7d1e fix: separate sync discovery from enrichment and harden Railway executor
Sync system was timing out because discovery endpoints (keyword, changes)
also ran schema extraction (~10-15s per tool). Now discovery is fast
(npm metadata + DB writes only) and a new /api/sync/enrich endpoint
handles schema extraction in time-budgeted chunks.

Railway executor was crashing without restarting due to unhandled promise
rejections, no restart policy, and no health checks. Added crash
protection, graceful shutdown, cache size limits, railway.toml with
ALWAYS restart policy, and upgraded Deno from 1.39 to 2.1.9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 04:49:54 +10:00
Ajax Davis
9ec8bf2454 fix(mcp): handle invalid type values and conflicting type+oneOf schemas
Extend schema sanitizer to handle two more invalid patterns found in
production: TypeScript-style type values like "string[]" and
"'markdown' | 'mdx'" (not valid JSON Schema), and properties with
conflicting type:"object" alongside oneOf containing string/array
types (e.g. resend sendEmail cc/to/bcc fields).
2026-02-07 21:24:30 +10:00
Ajax Davis
293fe08910 fix(mcp): sanitize tool inputSchema to prevent client crashes
Tool schemas from the database can contain JSON Schema keywords that
Claude's API rejects (minimum, maxLength, top-level oneOf, old $schema
drafts, etc.), causing a 400 error that crashes the entire client
session. Add sanitizeInputSchema() that recursively strips unsupported
keywords before returning tools via MCP tools/list.
2026-02-07 21:12:39 +10:00
Ajax Davis
15fc413f9c fix(mcp): sanitize tool inputSchema to prevent client crashes
Tool schemas from the database can contain JSON Schema keywords that
Claude's API rejects (minimum, maxLength, top-level oneOf, old $schema
drafts, etc.), causing a 400 error that crashes the entire client
session. Add sanitizeInputSchema() that recursively strips unsupported
keywords before returning tools via MCP tools/list.
2026-02-07 21:11:12 +10:00
Ajax Davis
a9e01fe772 fix: correct claude mcp add arg order in all docs and UI
The -H/--header flag is variadic and swallows subsequent positional
args when placed before them. Move name and URL before flags so the
CLI parses correctly. Also update docs from npx mcp-remote to native
HTTP transport.
2026-02-07 20:53:01 +10:00
Ajax Davis
36598fec61 feat: update MCP config to native HTTP transport and add moltbook tool
Switch collection MCP configs from npx mcp-remote to native HTTP
transport, fix Claude Code CLI arg order (options before name/url),
rename API key placeholder to YOUR_TPMJS_API_KEY, and add moltbook
social network tool to official blocks.
2026-02-07 19:40:54 +10:00
Ajax Davis
99017322ba fix(web): restore all collections features deleted in a52d32c
Restores Collections nav link in AppHeader, full collections/[id] detail
page with MCP URLs, dashboard collections Connect tab with McpUrlDisplay,
and CollectionDetailClient with ForkButton, CodeBlock, UseCases, and
Scenarios sections.
2026-02-07 03:41:06 +10:00
Ajax Davis
bd232a3407 fix(web): restore public collections listing page
The /collections page was incorrectly replaced with a redirect to /
in a previous commit. This restores the full public collections
listing with search, sorting, virtualized table, likes, and copy.
2026-02-07 02:36:47 +10:00
Ajax Davis
727a44af63 chore: gitignore symlinked skill dirs and update oclif manifest 2026-02-07 01:01:06 +10:00
Ajax Davis
4bcdab09f2 chore(cli): update oclif manifest after build 2026-02-07 00:58:18 +10:00
Ajax Davis
a52d32c367 feat: add resend tools, MCP collection endpoint, and UI refinements
- Add @tpmjs/tools-resend with email API tools and blocks.yml entries
- Add MCP route for collection skill discovery
- Add InstallationSection component for collections
- Refactor collection pages to use shared components and simplify layouts
- Update skills questions API, rate limiting, and API key handling
- Add tpmjs-tool-creator skill for Claude
- Update video feature scenes and fix lint issues
- Update .gitignore with IDE and temp file exclusions
2026-02-07 00:49:40 +10:00
Ajax Davis
f3a46045ba feat(web): add llms.txt for LLM-friendly site documentation
Follows the llmstxt.org specification to provide structured context
about TPMJS for AI assistants and language models.
2026-02-07 00:09:06 +10:00
Ajax Davis
54bfbff71c fix(tools-postmark): fix short description for registry validation
Lengthen deleteWebhook description to meet 20-char minimum required
by tpmjs field validator. Bump to 0.2.1.
2026-02-06 22:42:59 +10:00
Ajax Davis
90d575797f chore(release): @tpmjs/tools-postmark@0.2.0 2026-02-06 22:30:07 +10:00
Ajax Davis
0d30a9cabb feat(tools): add @tpmjs/tools-postmark with 82 Postmark API tools
Full Postmark email API coverage: send emails, manage templates,
bounces, domains, webhooks, message streams, stats, suppressions,
inbound rules, sender signatures, and data removals. Dual auth
with server token (60 tools) and account token (22 tools).
2026-02-06 22:14:37 +10:00
Ajax Davis
86e523f3dc Revert "feat(homepage): update positioning to "Infinite Toolspace""
This reverts commit ce44aeab3a.
2026-02-04 04:12:04 +10:00
Ajax Davis
ce44aeab3a feat(homepage): update positioning to "Infinite Toolspace"
Rebrand homepage messaging around toolspace virtualization concept:
- Hero: "INFINITE TOOLSPACE" with "A million tools. Zero configuration."
- New ToolspaceSection explaining the three-stage pipeline
- FeaturesSection emphasizes retrieval layer and selection at scale
- Integration section: "MCP is the socket, TPMJS is the OS"
- Publish section: "Join the Infinite Toolspace"
2026-02-04 03:45:50 +10:00
Ajax Davis
1675e6ce6c fix(ui): resolve lint errors to enable CI deployment
- Fix react-hooks warnings in Tooltip, Popover, DropdownMenu
- Fix react-hooks/static-components in ToolRenderer
- Fix useEffect/useCallback issues in useCountUp/useControlled
- Fix jsx-a11y warnings in Modal, Drawer
- Fix empty interface and type errors
- Add biome-ignore for semantic element warnings

These fixes enable CI to pass so dark mode text fix can deploy.
2026-02-04 02:52:05 +10:00
Ajax Davis
0f7e5a3ace chore: trigger CI for dark mode fix 2026-02-04 02:28:47 +10:00
Ajax Davis
fa4e7754e6 fix: improve dark mode text readability in chat interface
Add explicit text-foreground class to assistant message bubbles
to ensure proper contrast in dark mode.
2026-02-04 02:17:50 +10:00
Ajax Davis
32c6e097ed feat(executor): formalize Executor Protocol v1.0 with compliance testing
- Add EXECUTOR_SPECIFICATION.md with formal v1.0 protocol spec
- Add executor-openapi.yaml (OpenAPI 3.0 specification)
- Create @tpmjs/executor-test compliance test package (15 tests)
- Update Railway executor to v1.0 compliance (15/15 tests pass)
- Update Unsandbox executor to v1.0 compliance (15/15 tests pass)
- Update Vercel executor to v1.0 compliance
- Add /info endpoint with capability advertisement to all executors
- Add structured error codes (PACKAGE_NOT_FOUND, TOOL_NOT_FOUND, etc.)
- Add protocolVersion and implementationVersion to /health responses
- Add X-TPMJS-Protocol-Version header support
- Add EXECUTOR_COMPLIANCE.md with test results documentation
2026-02-04 02:09:08 +10:00
Ajax Davis
760cc4b77e chore: remove temporary admin endpoints 2026-02-04 00:32:21 +10:00
Ajax Davis
b092ca490b chore: add temporary admin endpoint for user analysis 2026-02-03 23:58:39 +10:00
Ajax Davis
ffc6ddcdbb feat(executors): add Railway executor template and documentation
- Add Railway executor template with one-click deploy support
- Create Railway documentation page at /docs/executors/railway
- Update main executors page with Railway as official recommendation
- Add Railway to platform comparison table with new columns
- Update inter-page navigation for Railway → Unsandbox → Vercel flow
- Update FAQ to recommend Railway for most use cases

Railway executor features:
- Zero-dependency Node.js HTTP server
- Docker support via included Dockerfile
- Health check endpoint at /health
- Tool execution at /execute-tool
- API key authentication support
- Auto-restart on failure via railway.json
2026-02-03 22:19:41 +10:00
Ajax Davis
3f62228c56 docs(executors): add Unsandbox and Vercel deployment guides
Restructure executor documentation to support multiple platforms:
- Main /docs/executors page now serves as overview with platform selector
- Add dedicated /docs/executors/unsandbox guide with CLI deployment
- Add dedicated /docs/executors/vercel guide with one-click deploy
- Include platform comparison table and shared API specification
2026-02-03 21:47:13 +10:00
Ajax Davis
ee066a20ca Add Unsandbox executor template
Adds a new executor template for deploying TPMJS tools on Unsandbox,
providing an alternative to the Vercel executor.

Features:
- One-command deploy via `un` CLI
- API-compatible with Vercel executor
- Standalone bootstrap script (no network required during bootstrap)
- Full documentation with examples
2026-02-03 21:29:22 +10:00
199 changed files with 28283 additions and 2605 deletions

1
.claude/skills/agentmail Symbolic link
View file

@ -0,0 +1 @@
../../.agents/skills/agentmail

View file

@ -0,0 +1,296 @@
---
name: tpmjs-tool-creator
description: Guide for creating official TPMJS tools using the blocks CLI. Use when a user wants to create a new tool for the TPMJS registry, add a tool to packages/tools/official/, implement an AI SDK v6 tool, define a block in blocks.yml, validate a tool with `pnpm blocks run`, or publish a tool to npm with the tpmjs keyword.
---
# TPMJS Tool Creator
Create production-ready tools for the TPMJS registry using the blocks CLI. Tools are npm packages following the AI SDK v6 pattern, validated by blocks, and automatically synced to tpmjs.com.
## Workflow
1. Define the tool block in `packages/tools/official/blocks.yml`
2. Create the tool package directory
3. Implement the tool using AI SDK v6 `tool()` + `jsonSchema()`
4. Validate with `pnpm blocks run <tool-name>`
5. Build and publish to npm
## Step 1: Define in blocks.yml
Add to the `blocks:` section of `packages/tools/official/blocks.yml`:
```yaml
blocks:
category.toolName:
type: utility
description: "LLM-friendly description of what the tool does"
path: "tool-directory-name"
domain_rules:
- id: rule_name
description: "What this implementation must do"
inputs:
- name: inputName
type: string
description: "Description for LLMs"
- name: optionalInput
type: number
optional: true
description: "Optional parameter"
outputs:
- name: result
type: ResultType
description: "What the tool returns"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
```
**Category prefix** (before the dot): `research`, `web`, `data`, `documentation`, `engineering`, `security`, `statistics`, `ops`, `agent`, `sandbox`, `utilities`, `html`, `compliance`, `finance`, `legal`, `hr`, `marketing`, `cx`, `edu`, `sales`.
For domain entities and quality measures, see [references/domain.md](references/domain.md).
## Step 2: Create Package Directory
Create `packages/tools/official/<tool-name>/`:
```
<tool-name>/
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── README.md
└── src/
└── index.ts
```
**package.json:**
```json
{
"name": "@tpmjs/official-<tool-name>",
"version": "0.1.0",
"description": "Short description",
"type": "module",
"keywords": ["tpmjs", "<category>", "ai"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist .turbo"
},
"devDependencies": {
"@tpmjs/tsconfig": "workspace:*",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
},
"dependencies": {
"ai": "6.0.49"
},
"publishConfig": { "access": "public" },
"repository": {
"type": "git",
"url": "https://github.com/tpmjs/tpmjs.git",
"directory": "packages/tools/official/<tool-name>"
},
"homepage": "https://tpmjs.com",
"license": "MIT",
"tpmjs": {
"category": "<category>",
"frameworks": ["vercel-ai"],
"tools": [
{
"name": "toolName",
"description": "Clear description (20+ chars)."
}
]
}
}
```
**tsconfig.json:**
```json
{
"extends": "@tpmjs/tsconfig/react-library.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
```
**tsup.config.ts:**
```typescript
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
target: 'es2022',
});
```
## Step 3: Implement the Tool
Every tool follows this AI SDK v6 pattern in `src/index.ts`:
```typescript
import { jsonSchema, tool } from 'ai';
interface MyToolInput {
param1: string;
param2?: number;
}
export interface MyToolResult {
data: string;
metadata: { processedAt: string };
}
export const myTool = tool({
description: 'Clear LLM-friendly description of what this tool does.',
parameters: jsonSchema<MyToolInput>({
type: 'object',
properties: {
param1: {
type: 'string',
description: 'What param1 is for',
},
param2: {
type: 'number',
description: 'Optional: what param2 is for',
},
},
required: ['param1'],
additionalProperties: false,
}),
execute: async (input): Promise<MyToolResult> => {
if (!input.param1) {
throw new Error('param1 is required and must be non-empty');
}
try {
const result = await processData(input.param1);
return {
data: result,
metadata: { processedAt: new Date().toISOString() },
};
} catch (error) {
throw new Error(
`Failed to process: ${error instanceof Error ? error.message : String(error)}`
);
}
},
});
export default myTool;
```
**Hard rules:**
- No stubs, TODOs, or placeholders — every tool must be fully working
- Single-shot: one call in, one structured result out
- Validate inputs before processing
- Try-catch with descriptive errors including context
- `additionalProperties: false` on jsonSchema
- Description on every schema property
- Export as both named and default export
- Output interface must be exported
### Multi-Tool Packages
For packages with multiple tools, add root-level files:
**block.ts:**
```typescript
import { toolA, toolB } from './src/index.js';
export const block = { name: 'package-name', tools: { toolA, toolB } };
export default block;
```
**index.ts (root):**
```typescript
export * from './src/index.js';
export { default } from './src/index.js';
```
Each tool gets its own entry in blocks.yml (same `path`) and in `tpmjs.tools` array.
## Step 4: Validate
The blocks CLI domain validator requires an OpenAI API key. Source it from `.env.local` before running:
```bash
cd packages/tools/official
# Load the OpenAI API key for domain validation
source ../../../.env.local
export OPENAI_API_KEY
pnpm blocks run <tool-name> # Validate (schema → shape → domain)
pnpm blocks run <tool-name> --force # Force full validation (skip cache)
pnpm blocks run <tool-name> --json # JSON output for debugging
pnpm blocks run --all # Validate all tools
```
**Common errors:**
- `Tool "X" not found in exports` → Export name must match blocks.yml
- `Required file not found` → Check package root has all required files
- `invalid tpmjs field` → Category must be valid, tools array required
## Step 5: Build and Publish
```bash
pnpm --filter=@tpmjs/official-<tool-name> build
cd packages/tools/official/<tool-name> && npm publish --access public
```
The tool syncs to tpmjs.com automatically via the changes feed (every 2 min) and keyword search (every 15 min). To trigger immediately:
```bash
source apps/web/.env.local
curl -X POST https://tpmjs.com/api/sync/keyword \
-H "Authorization: Bearer $CRON_SECRET"
```
## README Template
Every tool needs a README:
```markdown
# @tpmjs/official-<tool-name>
Short description.
## Installation
npm install @tpmjs/official-<tool-name>
## Usage
\`\`\`typescript
import { myTool } from '@tpmjs/official-<tool-name>';
const result = await myTool.execute({ param1: 'example' });
\`\`\`
## Parameters
| Name | Type | Required | Description |
|--------|--------|----------|--------------------|
| param1 | string | Yes | What param1 is for |
## Output
| Field | Type | Description |
|-------|--------|----------------------|
| data | string | The processed result |
## License
MIT
```

View file

@ -0,0 +1,58 @@
# Domain Reference
## Entities
Reusable output types defined in blocks.yml. Reference these in your tool's output `type` field.
| Entity | Fields |
|--------|--------|
| url | href, domain, protocol, path, query, fragment |
| webpage | url, title, html, text, metadata |
| text_content | raw, sentences, paragraphs, wordCount |
| claim | statement, confidence, needsCitation, category |
| timeline | events, dateRange, gaps, eventCount |
| evidence | source, type, strength, relevance |
| summary | text, keyPoints, length, compressionRatio |
| sentiment | score, label, confidence, aspects |
| entity | name, type, mentions, context |
| relationship | source, target, type, strength |
| pattern | name, frequency, examples, significance |
| anomaly | description, severity, context, recommendation |
| metric | name, value, unit, trend |
| comparison | items, criteria, rankings, analysis |
| recommendation | action, priority, rationale, impact |
| risk | description, likelihood, impact, mitigation |
| code_snippet | language, code, explanation, complexity |
| api_endpoint | method, path, parameters, response |
| data_schema | fields, types, constraints, relationships |
| workflow_step | action, input, output, conditions |
## Quality Measures
Reference these in your output's `measures` array.
| Measure | Severity | What it checks |
|---------|----------|---------------|
| working_implementation | error | No TODOs, stubs, or placeholders. Returns actual computed values. |
| valid_output_structure | error | Returns object matching declared interface. All required fields present. Arrays never undefined. |
| proper_error_handling | error | Throws descriptive Error with context. Validates inputs. Catches external API errors. |
| ai_sdk_compliance | error | Uses `tool()` + `jsonSchema()` from 'ai'. Clear description. Every property has description. |
| npm_publishable | error | Valid package.json with tpmjs field. Named + default exports. Proper types. Semver version. |
| readme_documentation | error | README exists. Describes tool. Usage example. Documents inputs/outputs. |
| deterministic_output | warning | Same input produces same output (where applicable). |
| minimal_dependencies | warning | Uses stable, well-maintained packages. Avoids unnecessary deps. |
## Domain Rules
Common domain rule categories for the `domain_rules` field in blocks.yml:
- **Core implementation**: working code, proper types, error handling
- **Web & fetch**: URL validation, content extraction, timeout handling
- **Document generation**: format compliance, template rendering
- **Data transformation**: schema validation, type coercion, encoding
- **Engineering/code analysis**: AST parsing, complexity metrics
- **Security & compliance**: input sanitization, safe execution
- **Statistical rigor**: numerical accuracy, proper rounding
- **Workflow/recipe**: step sequencing, state management
Define custom rules specific to your tool's requirements. Each rule needs an `id` and `description`.

72
.github/workflows/build-omega-mac.yml vendored Normal file
View file

@ -0,0 +1,72 @@
name: Build Omega Mac
on:
workflow_dispatch:
jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
- name: Resolve dependencies
working-directory: apps/omega-mac
run: swift package resolve
- name: Build release
working-directory: apps/omega-mac
run: swift build -c release
- name: Package .app bundle
working-directory: apps/omega-mac
run: |
mkdir -p OmegaMac.app/Contents/MacOS
mkdir -p OmegaMac.app/Contents/Resources
cp .build/release/OmegaMac OmegaMac.app/Contents/MacOS/OmegaMac
cat > OmegaMac.app/Contents/Info.plist << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>OmegaMac</string>
<key>CFBundleIdentifier</key>
<string>com.tpmjs.omega-mac</string>
<key>CFBundleName</key>
<string>Omega</string>
<key>CFBundleDisplayName</key>
<string>Omega</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
PLIST
codesign --force --sign - OmegaMac.app
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: OmegaMac
path: apps/omega-mac/OmegaMac.app

114
.github/workflows/sync-enrich.yml vendored Normal file
View file

@ -0,0 +1,114 @@
name: Sync Tool Enrichment
on:
schedule:
# Run every 2 minutes
- cron: '*/2 * * * *'
workflow_dispatch:
jobs:
sync-enrich:
runs-on: ubuntu-latest
steps:
- name: Trigger enrichment sync
id: sync
run: |
# Call the sync API and capture response
response=$(curl -X POST "${{ secrets.VERCEL_PRODUCTION_URL }}/api/sync/enrich" \
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
-H "Content-Type: application/json" \
-f -s -S)
echo "Response: $response"
# Extract data using jq
enriched=$(echo "$response" | jq -r '.data.enriched')
discovered=$(echo "$response" | jq -r '.data.discovered')
skipped=$(echo "$response" | jq -r '.data.skipped')
errors=$(echo "$response" | jq -r '.data.errors')
durationMs=$(echo "$response" | jq -r '.data.durationMs')
# Extract and display error messages
errorMessages=$(echo "$response" | jq -r '.data.errorMessages[]?' 2>/dev/null || echo "")
if [ -n "$errorMessages" ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ ENRICHMENT ERRORS ($errors total):"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "$response" | jq -r '.data.errorMessages[]?' | while IFS= read -r error; do
echo " • $error"
done
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
# Set outputs for Discord notification
echo "enriched=$enriched" >> $GITHUB_OUTPUT
echo "discovered=$discovered" >> $GITHUB_OUTPUT
echo "skipped=$skipped" >> $GITHUB_OUTPUT
echo "errors=$errors" >> $GITHUB_OUTPUT
echo "durationMs=$durationMs" >> $GITHUB_OUTPUT
# Store error messages for Discord (first 3, truncated)
if [ "$errors" -gt 0 ]; then
errorSummary=$(echo "$response" | jq -r '.data.errorMessages[0:3]? | join("\n• ")' 2>/dev/null || echo "")
if [ -n "$errorSummary" ]; then
echo "• $errorSummary" > /tmp/error_summary.txt
fi
fi
# Determine status emoji
if [ "$errors" -gt 0 ]; then
echo "status_emoji=⚠️" >> $GITHUB_OUTPUT
echo "status_color=16776960" >> $GITHUB_OUTPUT # Yellow
else
echo "status_emoji=✅" >> $GITHUB_OUTPUT
echo "status_color=5763719" >> $GITHUB_OUTPUT # Green
fi
- name: Send Discord notification
if: always()
run: |
# Format duration
duration_sec=$(echo "scale=2; ${{ steps.sync.outputs.durationMs }} / 1000" | bc)
# Build Discord payload using jq for proper JSON escaping
error_text=""
if [ -f /tmp/error_summary.txt ] && [ ${{ steps.sync.outputs.errors }} -gt 0 ]; then
error_text=$(cat /tmp/error_summary.txt | head -c 800)
fi
# Build fields array dynamically
base_fields='[
{ "name": "🔧 Enriched", "value": "${{ steps.sync.outputs.enriched }}", "inline": true },
{ "name": "🔍 Discovered", "value": "${{ steps.sync.outputs.discovered }}", "inline": true },
{ "name": "⏭️ Skipped", "value": "${{ steps.sync.outputs.skipped }}", "inline": true },
{ "name": "❌ Errors", "value": "${{ steps.sync.outputs.errors }}", "inline": true },
{ "name": "⏱️ Duration", "value": "'"${duration_sec}s"'", "inline": true },
{ "name": "🔗 Run", "value": "[View Logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
]'
# Create payload with dynamic fields
payload=$(jq -n \
--arg title "${{ steps.sync.outputs.status_emoji }} Tool Enrichment Sync" \
--argjson color ${{ steps.sync.outputs.status_color }} \
--argjson baseFields "$base_fields" \
--arg error_text "$error_text" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
'
{
embeds: [{
title: $title,
color: $color,
fields: (
$baseFields +
(if $error_text != "" then [{ name: "🔍 Error Details", value: ("```\n" + $error_text + "\n```"), inline: false }] else [] end)
),
timestamp: $timestamp
}]
}')
# Send to Discord
curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
-H "Content-Type: application/json" \
-d "$payload"

16
.gitignore vendored
View file

@ -63,6 +63,22 @@ secrets.json
# ide
.idea
.agent/
.agents/
.continue/
.cursor/
.windsurf/
# temporary analysis docs
COMPREHENSIVE_ANALYSIS.md
PLAN.md
REGISTRY_TOOLS_ANALYSIS.md
USER_ACCOUNT_ANALYSIS_*.md
*.skill
# symlinked skill dirs (source is .agents/ which is gitignored)
/skills/
.claude/skills/skill-creator
# storybook
storybook-static

1230
CLAUDE.md

File diff suppressed because it is too large Load diff

269
EXECUTOR_COMPLIANCE.md Normal file
View file

@ -0,0 +1,269 @@
# TPMJS Executor Compliance Report
> **Generated:** 2026-02-04
> **Protocol Version:** 1.0
> **Test Suite Version:** 0.1.0
## Overview
This document reports compliance testing results for the three reference TPMJS executor implementations against the Executor Protocol v1.0 specification.
## Compliance Summary
| Executor | Platform | Isolation | Core (L1) | Standard (L2) | Tests Passed |
|----------|----------|-----------|-----------|---------------|--------------|
| Railway Executor | Railway | Process | ✅ PASS | ✅ PASS | 15/15 |
| Unsandbox Executor | Unsandbox | Container | ✅ PASS | ✅ PASS | 15/15 |
| Vercel Executor | Vercel | VM | ✅ PASS* | ✅ PASS* | 15/15* |
\* Vercel Executor requires deployment to Vercel for full testing due to `@vercel/sandbox` dependency.
---
## Railway Executor
**Location:** `templates/railway-executor/`
### Test Results
```
TPMJS Executor Compliance Test v0.1.0
Protocol Version: 1.0
Target: http://localhost:3456
Core Core Requirements:
✓ GET /health returns 200 (65ms)
✓ GET /health includes protocolVersion (5ms)
✓ GET /health includes implementationVersion (5ms)
✓ POST /execute-tool accepts valid request (4425ms)
✓ POST /execute-tool returns structured response (2202ms)
✓ POST /execute-tool returns error for invalid tool (1556ms)
✓ CORS headers present (3ms)
✓ OPTIONS preflight works (2ms)
Standard Standard Requirements:
✓ GET /info returns 200 (6ms)
✓ GET /info includes capabilities (3ms)
✓ GET /info includes protocolVersion (3ms)
✓ capabilities.isolation is valid (2ms)
✓ Authentication enforced when configured (2181ms)
✓ Execution timeout enforcement (2ms)
✓ Structured error codes (2307ms)
Summary:
Tests: 15 passed, 0 failed, 15 total
Core Compliance: PASS
Standard Compliance: PASS
```
### Capabilities
```json
{
"name": "Railway Executor",
"version": "1.0.0",
"protocolVersion": "1.0",
"capabilities": {
"isolation": "process",
"executionModes": ["sync"],
"maxExecutionTimeMs": 120000,
"maxRequestBodyBytes": 10485760,
"supportsStreaming": false,
"supportsCallbacks": false,
"supportsCaching": false
}
}
```
### Deployment
```bash
# Deploy to Railway
railway init
railway up
# Or use the Docker image
docker build -t tpmjs-executor .
docker run -p 3000:3000 tpmjs-executor
```
---
## Unsandbox Executor
**Location:** `templates/unsandbox-executor/`
### Test Results
```
TPMJS Executor Compliance Test v0.1.0
Protocol Version: 1.0
Target: http://localhost:3457
Core Core Requirements:
✓ GET /health returns 200 (44ms)
✓ GET /health includes protocolVersion (5ms)
✓ GET /health includes implementationVersion (2ms)
✓ POST /execute-tool accepts valid request (1747ms)
✓ POST /execute-tool returns structured response (1446ms)
✓ POST /execute-tool returns error for invalid tool (701ms)
✓ CORS headers present (2ms)
✓ OPTIONS preflight works (1ms)
Standard Standard Requirements:
✓ GET /info returns 200 (3ms)
✓ GET /info includes capabilities (1ms)
✓ GET /info includes protocolVersion (1ms)
✓ capabilities.isolation is valid (0ms)
✓ Authentication enforced when configured (1926ms)
✓ Execution timeout enforcement (1ms)
✓ Structured error codes (744ms)
Summary:
Tests: 15 passed, 0 failed, 15 total
Core Compliance: PASS
Standard Compliance: PASS
```
### Capabilities
```json
{
"name": "Unsandbox Executor",
"version": "1.0.0",
"protocolVersion": "1.0",
"capabilities": {
"isolation": "container",
"executionModes": ["sync"],
"maxExecutionTimeMs": 120000,
"maxRequestBodyBytes": 10485760,
"supportsStreaming": false,
"supportsCallbacks": false,
"supportsCaching": false
}
}
```
### Deployment
See `templates/unsandbox-executor/README.md` for Unsandbox deployment instructions.
---
## Vercel Executor
**Location:** `templates/vercel-executor/`
### Capabilities
```json
{
"name": "Vercel Sandbox Executor",
"version": "1.0.0",
"protocolVersion": "1.0",
"capabilities": {
"isolation": "vm",
"executionModes": ["sync"],
"maxExecutionTimeMs": 120000,
"maxRequestBodyBytes": 10485760,
"supportsStreaming": false,
"supportsCallbacks": false,
"supportsCaching": false
}
}
```
### Deployment
```bash
# Deploy to Vercel
vercel
# Or link and deploy
vercel link
vercel deploy --prod
```
### Notes
The Vercel Executor uses `@vercel/sandbox` which provides VM-level isolation (strongest isolation level). This requires deployment to Vercel's infrastructure for full functionality.
---
## Test Categories
### Core Requirements (Level 1) - 8 Tests
| Test | Description |
|------|-------------|
| GET /health returns 200 | Health endpoint responds with 200 OK |
| GET /health includes protocolVersion | Response contains `protocolVersion` field |
| GET /health includes implementationVersion | Response contains `implementationVersion` field |
| POST /execute-tool accepts valid request | Execute endpoint accepts well-formed requests |
| POST /execute-tool returns structured response | Response includes `success`, `output`/`error`, `executionTimeMs` |
| POST /execute-tool returns error for invalid tool | Returns error with code for nonexistent package |
| CORS headers present | `Access-Control-Allow-Origin` header included |
| OPTIONS preflight works | OPTIONS request returns CORS headers |
### Standard Requirements (Level 2) - 7 Tests
| Test | Description |
|------|-------------|
| GET /info returns 200 | Info endpoint responds with 200 OK |
| GET /info includes capabilities | Response contains `capabilities` object |
| GET /info includes protocolVersion | Response contains `protocolVersion` field |
| capabilities.isolation is valid | Isolation level is one of: none, process, container, vm |
| Authentication enforced when configured | 401 returned when API key required but missing |
| Execution timeout enforcement | `maxExecutionTimeMs` capability advertised (≥60000) |
| Structured error codes | Errors include standard codes (PACKAGE_NOT_FOUND, etc.) |
---
## Running Compliance Tests
### Using npx (Published)
```bash
npx @tpmjs/executor-test https://your-executor.example.com
```
### Using Local Build
```bash
cd packages/executor-test
pnpm build
node bin/run.js https://your-executor.example.com
```
### With Authentication
```bash
npx @tpmjs/executor-test https://your-executor.example.com --api-key sk-xxx
```
### JSON Output
```bash
npx @tpmjs/executor-test https://your-executor.example.com --json
```
---
## Specification Reference
- **EXECUTOR_SPECIFICATION.md** - Full protocol specification
- **executor-openapi.yaml** - OpenAPI 3.0 specification
- **packages/executor-test/** - Compliance test suite source
---
## Changelog
### 2026-02-04
- Initial compliance testing
- All 3 executors updated to v1.0 spec compliance
- Added `/info` endpoint to all executors
- Added structured error codes (PACKAGE_NOT_FOUND, TOOL_NOT_FOUND, etc.)
- Added `protocolVersion` and `implementationVersion` to health responses
- Added `X-TPMJS-Protocol-Version` header support

496
EXECUTOR_SPECIFICATION.md Normal file
View file

@ -0,0 +1,496 @@
# TPMJS Executor Protocol Specification v1.0
> **Status:** Draft
> **Version:** 1.0.0
> **Last Updated:** 2026-02-03
## Overview
The TPMJS Executor Protocol defines a standard HTTP interface for executing TPMJS tools. Executors are **compute adapters** that provide a consistent API for running npm-packaged tools regardless of the underlying infrastructure.
### Design Philosophy
- **HTTP-First:** No SDK lock-in, deployable anywhere
- **Minimal Surface:** Small core, optional extensions
- **Executor ≠ Sandbox:** Standardize coordination, not security
- **Declare, Don't Enforce:** Executors report capabilities, TPMJS decides policy
### Relationship to Other Specs
| Spec | Purpose |
|------|---------|
| **MCP** | Model ↔ Tool interface |
| **TPMJS Executor** | Tool ↔ Compute interface |
| **TPMJS Tools** | Tool contract (separate spec) |
---
## Protocol Versioning
### Version Header
All requests SHOULD include:
```http
X-TPMJS-Protocol-Version: 1.0
```
Executors MUST respond with their supported protocol version in `/health` and `/info` responses.
**Rationale:** Header-based versioning enables graceful evolution without URL fragmentation.
---
## Specification Levels
### Level 1: Core (REQUIRED)
Every executor MUST implement:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/health` | GET | Liveness + protocol discovery |
| `/execute-tool` | POST | Synchronous tool execution |
### Level 2: Standard (RECOMMENDED)
Executors SHOULD implement:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/info` | GET | Capability advertisement |
Plus:
- API key authentication
- Structured error responses
- Execution timeout enforcement
- CORS headers
### Level 3: Extended (OPTIONAL)
Reserved for future versions:
- `POST /execute-tool` with `Accept: text/event-stream` (streaming)
- `POST /execute-async` (webhook callbacks)
- `POST /validate-tool` (dry-run validation)
- `POST /execute-batch` (multiple tools)
---
## Core Endpoints
### GET /health
**Purpose:** Verify executor is running and discover protocol version.
**Response (200 OK):**
```json
{
"status": "ok",
"protocolVersion": "1.0",
"implementationVersion": "1.0.0",
"runtime": "node",
"timestamp": "2026-02-03T12:00:00.000Z"
}
```
**Fields:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `status` | string | Yes | Always `"ok"` if healthy |
| `protocolVersion` | string | Yes | TPMJS protocol version (e.g., `"1.0"`) |
| `implementationVersion` | string | Yes | Executor software version |
| `runtime` | string | No | Runtime identifier (e.g., `"node"`, `"deno"`, `"bun"`) |
| `timestamp` | string | No | ISO 8601 timestamp |
**Requirements:**
- MUST respond within 1 second
- MUST return 200 OK if healthy
- MUST include `protocolVersion`
---
### POST /execute-tool
**Purpose:** Execute a single TPMJS tool synchronously.
**Request Headers:**
```http
Content-Type: application/json
Authorization: Bearer <api-key> (if auth enabled)
X-TPMJS-Protocol-Version: 1.0
```
**Request Body:**
```json
{
"packageName": "@tpmjs/hello",
"version": "latest",
"name": "helloWorldTool",
"params": {
"greeting": "Hello"
},
"env": {
"OPENAI_API_KEY": "sk-..."
}
}
```
**Request Fields:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `packageName` | string | Yes | npm package name |
| `version` | string | No | Package version (default: `"latest"`) |
| `name` | string | Yes | Tool export name |
| `params` | object | No | Parameters passed to `tool.execute()` |
| `env` | object | No | Environment variables for execution |
**Success Response (200 OK):**
```json
{
"success": true,
"output": {
"message": "Hello, World!"
},
"executionTimeMs": 1234
}
```
**Error Response (200 OK):**
```json
{
"success": false,
"error": {
"code": "TOOL_EXECUTION_ERROR",
"message": "Tool threw an error: Invalid input"
},
"executionTimeMs": 123
}
```
**Response Fields:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `success` | boolean | Yes | Whether execution succeeded |
| `output` | any | If success | Return value from `tool.execute()` |
| `error` | object | If failed | Error details |
| `error.code` | string | If failed | Machine-readable error code |
| `error.message` | string | If failed | Human-readable error message |
| `executionTimeMs` | number | Yes | Total execution time in milliseconds |
**Error Codes:**
| Code | Description |
|------|-------------|
| `PACKAGE_NOT_FOUND` | npm package could not be installed |
| `TOOL_NOT_FOUND` | Named export not found in package |
| `TOOL_INVALID` | Export exists but has no `.execute()` method |
| `TOOL_EXECUTION_ERROR` | Tool threw during execution |
| `EXECUTION_TIMEOUT` | Execution exceeded time limit |
| `INTERNAL_ERROR` | Unexpected executor error |
---
## Standard Endpoints
### GET /info
**Purpose:** Advertise executor capabilities for intelligent routing.
**Response (200 OK):**
```json
{
"name": "Railway Executor",
"version": "1.0.0",
"protocolVersion": "1.0",
"capabilities": {
"isolation": "process",
"executionModes": ["sync"],
"maxExecutionTimeMs": 120000,
"maxRequestBodyBytes": 10485760,
"supportsStreaming": false,
"supportsCallbacks": false,
"supportsCaching": false
},
"runtime": {
"platform": "linux",
"nodeVersion": "20.10.0",
"region": "us-west-1"
}
}
```
**Capability Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `isolation` | string | `"none"` \| `"process"` \| `"container"` \| `"vm"` |
| `executionModes` | array | `["sync"]` (future: `"stream"`, `"async"`) |
| `maxExecutionTimeMs` | number | Maximum execution time before timeout |
| `maxRequestBodyBytes` | number | Maximum request body size |
| `supportsStreaming` | boolean | Reserved for v1.1 |
| `supportsCallbacks` | boolean | Reserved for v1.1 |
| `supportsCaching` | boolean | Reserved for v1.1 |
**Isolation Levels:**
| Level | Description |
|-------|-------------|
| `none` | Tools run in executor process (development only) |
| `process` | Tools run in separate OS process |
| `container` | Tools run in isolated container |
| `vm` | Tools run in isolated VM (strongest) |
---
## Authentication
### v1.0: API Key Only
Executors MAY require authentication via Bearer token.
**Request Header:**
```http
Authorization: Bearer <api-key>
```
**Configuration:**
Executors SHOULD use `EXECUTOR_API_KEY` environment variable:
- If set: All requests MUST include valid Bearer token
- If unset: No authentication required
**Unauthorized Response (401):**
```json
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or missing API key"
}
}
```
**Future Versions:** JWT, OAuth, and per-tool authentication are deferred to v1.1+.
---
## CORS Requirements
All executors MUST support CORS for browser-based clients.
**Required Headers:**
```http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-TPMJS-Protocol-Version
```
**OPTIONS Preflight:**
All endpoints MUST handle OPTIONS requests and return CORS headers with 200 OK.
---
## Execution Lifecycle
### Standard Flow
1. **Receive Request:** Parse JSON body, validate required fields
2. **Check Auth:** Verify API key if configured
3. **Create Isolation:** Create temporary execution environment
4. **Install Package:** Run `npm install <package>@<version>`
5. **Load Tool:** Import package, resolve named export
6. **Execute:** Call `tool.execute(params)` with environment
7. **Capture Result:** Collect output or error
8. **Cleanup:** Remove temporary files/processes
9. **Respond:** Return JSON response
### Tool Resolution
Executors MUST resolve a callable tool with an `.execute()` method.
**Recommended Resolution Order:**
1. `pkg[name]` - Direct named export
2. `pkg.default?.[name]` - Named property on default export
3. `pkg.default` - Default export itself (if `name` matches)
**Factory Functions:**
If export is a function without `.execute()`:
1. Try calling `tool()` with no arguments
2. Check if result has `.execute()` method
**Note:** Tool export patterns are intentionally not fully standardized in v1.0 to allow ecosystem evolution.
---
## Timeouts
### Required Timeouts
| Phase | Minimum | Recommended |
|-------|---------|-------------|
| npm install | 30s | 60s |
| Tool execution | 60s | 120s |
| Total request | 90s | 180s |
Executors MUST:
- Enforce execution timeouts
- Return `EXECUTION_TIMEOUT` error code when exceeded
- Clean up resources on timeout
---
## Error Handling
### HTTP Status Codes
| Code | Usage |
|------|-------|
| 200 | Successful execution OR tool error (with `success: false`) |
| 400 | Invalid request (missing fields, malformed JSON) |
| 401 | Authentication required but missing/invalid |
| 404 | Unknown endpoint |
| 500 | Internal executor error |
### Structured Errors
All error responses MUST include:
```json
{
"success": false,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description"
}
}
```
---
## Implementation Checklist
### Core (Required for Compliance)
- [ ] `GET /health` returns status and protocol version
- [ ] `POST /execute-tool` accepts standard request format
- [ ] Returns `{ success, output/error, executionTimeMs }`
- [ ] Handles missing/invalid request body (400)
- [ ] CORS headers on all responses
- [ ] OPTIONS preflight handling
### Standard (Recommended)
- [ ] `GET /info` with capabilities
- [ ] `EXECUTOR_API_KEY` environment variable support
- [ ] Bearer token validation (401 on failure)
- [ ] Execution timeout enforcement
- [ ] npm install timeout (60s recommended)
- [ ] Temporary file cleanup
- [ ] Structured error codes
### Extended (Optional)
- [ ] Package caching
- [ ] Concurrent execution limiting
- [ ] Support for both `/path` and `/api/path` routes
- [ ] Region/metadata in `/info` response
---
## Compliance Testing
Use the official compliance test suite:
```bash
npx @tpmjs/executor-test https://my-executor.example.com
```
Output:
```
TPMJS Executor Compliance Test v1.0.0
Target: https://my-executor.example.com
Core Requirements:
✓ GET /health returns 200
✓ GET /health includes protocolVersion
✓ POST /execute-tool accepts valid request
✓ POST /execute-tool returns success response
✓ POST /execute-tool returns error for invalid tool
✓ CORS headers present
✓ OPTIONS preflight works
Standard Requirements:
✓ GET /info returns capabilities
✓ Authentication enforced when configured
✓ Execution timeout enforced
✗ Missing: maxExecutionTimeMs in capabilities
Result: 10/11 tests passed (Core: PASS, Standard: PARTIAL)
```
---
## Reference Implementations
| Name | Platform | Isolation | Source |
|------|----------|-----------|--------|
| Railway Executor | Railway | Process | `templates/railway-executor/` |
| Vercel Executor | Vercel | VM (Sandbox) | `templates/vercel-executor/` |
| Unsandbox Executor | Unsandbox | Container | `templates/unsandbox-executor/` |
---
## Future Roadmap
### v1.1 (Planned)
- Streaming responses (`Accept: text/event-stream`)
- Async execution with webhooks
- Caching hints (`X-TPMJS-Cache-*` headers)
- Tool validation endpoint
### v2.0 (Exploration)
- Multi-tool batch execution
- Persistent execution contexts
- Resource quotas and billing hooks
- MCP bridge protocol
---
## Changelog
### v1.0.0 (2026-02-03)
- Initial formal specification
- Core: `/health`, `/execute-tool`
- Standard: `/info`, API key auth
- Capability negotiation
- Compliance test suite
---
## Appendix: OpenAPI Specification
See `executor-openapi.yaml` for the formal OpenAPI 3.0 specification.
## Appendix: JSON Schemas
See `packages/types/src/executor.ts` for TypeScript types and Zod schemas.

1
apps/omega-mac/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.build

View file

@ -0,0 +1,20 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0.996",
"green": "0.475",
"red": "0.325"
}
},
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View file

@ -0,0 +1,58 @@
{
"images": [
{
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"idiom": "mac",
"scale": "2x",
"size": "512x512"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View file

@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View file

@ -0,0 +1,41 @@
import Foundation
import SwiftData
@Model
final class Conversation {
var id: UUID
var title: String?
var createdAt: Date
var updatedAt: Date
var executionState: String // "idle" | "running"
var inputTokensTotal: Int
var outputTokensTotal: Int
@Relationship(deleteRule: .cascade, inverse: \Message.conversation)
var messages: [Message]
@Relationship(deleteRule: .cascade, inverse: \ToolCallRecord.conversation)
var toolRuns: [ToolCallRecord]
init(
title: String? = nil
) {
self.id = UUID()
self.title = title
self.createdAt = Date()
self.updatedAt = Date()
self.executionState = "idle"
self.inputTokensTotal = 0
self.outputTokensTotal = 0
self.messages = []
self.toolRuns = []
}
var displayTitle: String {
title ?? "New Conversation"
}
var sortedMessages: [Message] {
messages.sorted { $0.createdAt < $1.createdAt }
}
}

View file

@ -0,0 +1,18 @@
import Foundation
import SwiftData
@Model
final class EnvVar {
var id: UUID
var keyName: String
/// Last 4 characters of the value (for display hint)
var valueHint: String
var createdAt: Date
init(keyName: String, valueHint: String) {
self.id = UUID()
self.keyName = keyName
self.valueHint = valueHint
self.createdAt = Date()
}
}

View file

@ -0,0 +1,141 @@
import Foundation
import SwiftData
enum MessageRole: String, Codable {
case user = "USER"
case assistant = "ASSISTANT"
case tool = "TOOL"
case system = "SYSTEM"
}
@Model
final class Message {
var id: UUID
var role: MessageRole
var content: String
var createdAt: Date
var inputTokens: Int?
var outputTokens: Int?
/// JSON-encoded array of tool calls (for assistant messages)
var toolCallsJSON: Data?
var conversation: Conversation?
init(
role: MessageRole,
content: String,
conversation: Conversation? = nil,
inputTokens: Int? = nil,
outputTokens: Int? = nil,
toolCalls: [ToolCallData]? = nil
) {
self.id = UUID()
self.role = role
self.content = content
self.createdAt = Date()
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.conversation = conversation
if let toolCalls {
self.toolCallsJSON = try? JSONEncoder().encode(toolCalls)
}
}
var toolCalls: [ToolCallData] {
get {
guard let data = toolCallsJSON else { return [] }
return (try? JSONDecoder().decode([ToolCallData].self, from: data)) ?? []
}
set {
toolCallsJSON = try? JSONEncoder().encode(newValue)
}
}
}
/// Serializable tool call data stored in messages
struct ToolCallData: Codable, Identifiable {
var id: String { toolCallId }
let toolCallId: String
let toolName: String
let args: JSONValue?
let output: JSONValue?
init(toolCallId: String, toolName: String, args: JSONValue? = nil, output: JSONValue? = nil) {
self.toolCallId = toolCallId
self.toolName = toolName
self.args = args
self.output = output
}
}
/// A type-erased JSON value for encoding/decoding arbitrary JSON
enum JSONValue: Codable, Equatable, Sendable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let b = try? container.decode(Bool.self) {
self = .bool(b)
} else if let n = try? container.decode(Double.self) {
self = .number(n)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else if let arr = try? container.decode([JSONValue].self) {
self = .array(arr)
} else if let obj = try? container.decode([String: JSONValue].self) {
self = .object(obj)
} else {
self = .null
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
case .bool(let b): try container.encode(b)
case .object(let o): try container.encode(o)
case .array(let a): try container.encode(a)
case .null: try container.encodeNil()
}
}
/// Convert any Codable/Sendable value to JSONValue
static func from(_ value: Any) -> JSONValue {
if let s = value as? String { return .string(s) }
if let n = value as? NSNumber {
if CFBooleanGetTypeID() == CFGetTypeID(n) {
return .bool(n.boolValue)
}
return .number(n.doubleValue)
}
if let b = value as? Bool { return .bool(b) }
if let i = value as? Int { return .number(Double(i)) }
if let d = value as? Double { return .number(d) }
if let arr = value as? [Any] { return .array(arr.map { from($0) }) }
if let obj = value as? [String: Any] {
return .object(obj.mapValues { from($0) })
}
return .null
}
/// Pretty-print JSON
var prettyString: String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
guard let data = try? encoder.encode(self),
let str = String(data: data, encoding: .utf8) else {
return "null"
}
return str
}
}

View file

@ -0,0 +1,51 @@
import Foundation
import SwiftData
@Model
final class ToolCallRecord {
var id: UUID
var toolName: String
var toolCallId: String
var status: String // "running" | "success" | "error"
var inputJSON: Data?
var outputJSON: Data?
var errorMessage: String?
var executionTimeMs: Int?
var createdAt: Date
var completedAt: Date?
var conversation: Conversation?
init(
toolName: String,
toolCallId: String,
conversation: Conversation? = nil
) {
self.id = UUID()
self.toolName = toolName
self.toolCallId = toolCallId
self.status = "running"
self.createdAt = Date()
self.conversation = conversation
}
var input: JSONValue? {
get {
guard let data = inputJSON else { return nil }
return try? JSONDecoder().decode(JSONValue.self, from: data)
}
set {
inputJSON = try? JSONEncoder().encode(newValue)
}
}
var output: JSONValue? {
get {
guard let data = outputJSON else { return nil }
return try? JSONDecoder().decode(JSONValue.self, from: data)
}
set {
outputJSON = try? JSONEncoder().encode(newValue)
}
}
}

View file

@ -0,0 +1,17 @@
import Foundation
import SwiftData
@Model
final class UserSettings {
var id: UUID
var systemPrompt: String?
var selectedModel: String
var pinnedToolIds: [String]
init() {
self.id = UUID()
self.systemPrompt = nil
self.selectedModel = "gpt-4.1-mini"
self.pinnedToolIds = []
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.tpmjs.omega-mac</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,44 @@
import SwiftData
import SwiftUI
@main
struct OmegaMacApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.dark)
}
.modelContainer(for: [
Conversation.self,
Message.self,
ToolCallRecord.self,
EnvVar.self,
UserSettings.self,
])
.defaultSize(width: 1100, height: 750)
.commands {
CommandGroup(replacing: .newItem) {
Button("New Conversation") {
NotificationCenter.default.post(
name: .newConversation, object: nil)
}
.keyboardShortcut("n", modifiers: .command)
}
}
#if os(macOS)
Settings {
SettingsView()
.modelContainer(for: [
EnvVar.self,
UserSettings.self,
])
.preferredColorScheme(.dark)
}
#endif
}
}
extension Notification.Name {
static let newConversation = Notification.Name("newConversation")
}

View file

@ -0,0 +1,613 @@
import Foundation
import SwiftData
/// Represents a live tool call being displayed during streaming
struct LiveToolCall: Identifiable, Sendable {
let id: String // toolCallId
let toolName: String
var arguments: String
var status: String // "running" | "success" | "error"
var output: JSONValue?
}
/// Main orchestrator for the Omega agentic chat loop.
/// Coordinates between OpenAI, TPMJS registry, and SwiftData persistence.
@MainActor
@Observable
final class ChatOrchestrator {
// MARK: - Published State
var streamingContent: String = ""
var isStreaming: Bool = false
var liveToolCalls: [LiveToolCall] = []
var error: String?
// MARK: - Private State
private let openAI = OpenAIService()
private let registry = TPMJSRegistryService()
/// Dynamically loaded tools for the current conversation (sanitizedName -> ToolMeta)
private var loadedTools: [String: ToolMeta] = [:]
/// Maximum agentic loop iterations (search -> execute -> respond)
private let maxIterations = 10
// MARK: - Public API
/// Send a user message and run the full agentic loop.
/// Streams the response, handles tool calls, and persists everything to SwiftData.
func sendMessage(
_ text: String,
conversation: Conversation,
modelContext: ModelContext
) async {
// Reset state
streamingContent = ""
isStreaming = true
liveToolCalls = []
error = nil
// Get API key
guard let apiKey = KeychainService.load(key: "OPENAI_API_KEY"), !apiKey.isEmpty else {
error = "No OpenAI API key set. Open Settings (Cmd+,) to add your key."
isStreaming = false
return
}
// Load user settings
let settingsDescriptor = FetchDescriptor<UserSettings>()
let settings = (try? modelContext.fetch(settingsDescriptor))?.first
let model = settings?.selectedModel ?? "gpt-4.1-mini"
let customPrompt = settings?.systemPrompt
let pinnedToolIds = settings?.pinnedToolIds ?? []
// Save user message
let userMessage = Message(role: .user, content: text, conversation: conversation)
modelContext.insert(userMessage)
conversation.updatedAt = Date()
conversation.executionState = "running"
try? modelContext.save()
// Load env vars from Keychain
let envVarDescriptor = FetchDescriptor<EnvVar>()
let envVarRecords = (try? modelContext.fetch(envVarDescriptor)) ?? []
let envVars = KeychainService.loadAllEnvVars(keyNames: envVarRecords.map(\.keyName))
// Auto-discover tools via BM25 search
do {
let relevantTools = try await registry.searchTools(query: text, limit: 10)
for toolMeta in relevantTools {
let sanitized = sanitizeToolName(toolMeta.toolId)
if loadedTools[sanitized] == nil {
loadedTools[sanitized] = toolMeta
}
}
} catch {
// Non-fatal: continue without auto-discovered tools
print("Auto-discovery failed: \(error)")
}
// Build messages array from conversation history
var chatMessages = buildChatMessages(
conversation: conversation,
customPrompt: customPrompt,
pinnedToolIds: pinnedToolIds
)
// Add the new user message
chatMessages.append(.user(text))
// Build tools list
let tools = buildToolsList()
// Agentic loop
var iteration = 0
var allToolCallData: [ToolCallData] = []
var allToolResultData: [ToolCallData] = []
var totalInputTokens = 0
var totalOutputTokens = 0
while iteration < maxIterations {
iteration += 1
var currentContent = ""
var pendingToolCalls: [ChatToolCall] = []
var receivedDone = false
do {
let stream = await openAI.streamCompletion(
apiKey: apiKey,
model: model,
messages: chatMessages,
tools: tools.isEmpty ? nil : tools
)
for try await event in stream {
switch event {
case .contentDelta(let delta):
currentContent += delta
streamingContent = currentContent
case .toolCallStarted(_, let id, let name):
let liveTC = LiveToolCall(
id: id,
toolName: name,
arguments: "",
status: "running"
)
liveToolCalls.append(liveTC)
case .toolCallArgumentDelta(let index, let delta):
if index < liveToolCalls.count {
liveToolCalls[index].arguments += delta
}
case .toolCallComplete(let toolCall):
pendingToolCalls.append(toolCall)
case .usage(let input, let output):
totalInputTokens += input
totalOutputTokens += output
case .done:
receivedDone = true
case .error(let msg):
self.error = msg
}
}
} catch {
self.error = error.localizedDescription
break
}
// If we got content with no tool calls, we're done
if pendingToolCalls.isEmpty {
streamingContent = currentContent
break
}
// Process tool calls
// Add assistant message with tool calls to chat history
chatMessages.append(.assistant(
content: currentContent.isEmpty ? nil : currentContent,
toolCalls: pendingToolCalls
))
// Execute each tool call
for toolCall in pendingToolCalls {
let tcData = ToolCallData(
toolCallId: toolCall.id,
toolName: toolCall.toolName,
args: .object(toolCall.parsedArguments)
)
allToolCallData.append(tcData)
// Record tool run
let record = ToolCallRecord(
toolName: toolCall.toolName,
toolCallId: toolCall.id,
conversation: conversation
)
record.input = .object(toolCall.parsedArguments)
modelContext.insert(record)
let result = await executeToolCall(
toolCall: toolCall,
envVars: envVars
)
// Update live tool call status
if let idx = liveToolCalls.firstIndex(where: { $0.id == toolCall.id }) {
liveToolCalls[idx].status = result.isError ? "error" : "success"
liveToolCalls[idx].output = result.output
}
// Update record
record.output = result.output
record.status = result.isError ? "error" : "success"
record.completedAt = Date()
// Add tool result to chat messages
let resultJSON: String
if let data = try? JSONEncoder().encode(result.output) {
resultJSON = String(data: data, encoding: .utf8) ?? "{}"
} else {
resultJSON = "{}"
}
chatMessages.append(.toolResult(
toolCallId: toolCall.id,
name: toolCall.toolName,
content: resultJSON
))
let trData = ToolCallData(
toolCallId: toolCall.id,
toolName: toolCall.toolName,
args: .object(toolCall.parsedArguments),
output: result.output
)
allToolResultData.append(trData)
}
// Reset streaming for next iteration
streamingContent = ""
liveToolCalls = []
}
// Save assistant message
let assistantMessage = Message(
role: .assistant,
content: streamingContent,
conversation: conversation,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
toolCalls: allToolCallData.isEmpty ? nil : allToolCallData
)
modelContext.insert(assistantMessage)
// Save tool results as a TOOL message if we had tool calls
if !allToolResultData.isEmpty {
let toolMessage = Message(
role: .tool,
content: "Tool results",
conversation: conversation,
toolCalls: allToolResultData
)
modelContext.insert(toolMessage)
}
// Update conversation
conversation.executionState = "idle"
conversation.inputTokensTotal += totalInputTokens
conversation.outputTokensTotal += totalOutputTokens
conversation.updatedAt = Date()
// Auto-title from first message
if conversation.title == nil {
let title = text.count > 50 ? String(text.prefix(50)) + "..." : text
conversation.title = title
}
try? modelContext.save()
isStreaming = false
}
/// Clear loaded tools (when switching conversations)
func resetConversation() {
loadedTools = [:]
streamingContent = ""
isStreaming = false
liveToolCalls = []
error = nil
}
// MARK: - Private Helpers
private struct ToolResult {
let output: JSONValue
let isError: Bool
}
private func executeToolCall(
toolCall: ChatToolCall,
envVars: [String: String]
) async -> ToolResult {
let name = toolCall.toolName
let args = toolCall.parsedArguments
// Handle registrySearch
if name == "registrySearch" {
return await handleRegistrySearch(args: args)
}
// Handle registryExecute
if name == "registryExecute" {
return await handleRegistryExecute(args: args, envVars: envVars)
}
// Handle dynamic tools (loaded from search)
if let toolMeta = loadedTools[name] {
return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
}
// Also check by finding the tool ID from the sanitized name
if let toolId = findToolId(sanitizedName: name, in: loadedTools),
let toolMeta = loadedTools.values.first(where: { $0.toolId == toolId }) {
return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
}
return ToolResult(
output: .object([
"error": .bool(true),
"message": .string("Unknown tool: \(name)"),
]),
isError: true
)
}
private func handleRegistrySearch(args: [String: JSONValue]) async -> ToolResult {
guard case .string(let query) = args["query"] else {
return ToolResult(
output: .object(["error": .bool(true), "message": .string("Missing 'query' parameter")]),
isError: true
)
}
let limit: Int
if case .number(let n) = args["limit"] {
limit = Int(n)
} else {
limit = 5
}
do {
let tools = try await registry.searchTools(query: query, limit: limit)
// Inject found tools into loaded tools
for toolMeta in tools {
let sanitized = sanitizeToolName(toolMeta.toolId)
if loadedTools[sanitized] == nil {
loadedTools[sanitized] = toolMeta
}
}
let toolsJSON: [JSONValue] = tools.map { t in
.object([
"toolId": .string(t.toolId),
"name": .string(t.name),
"package": .string(t.packageName),
"description": .string(t.description),
])
}
return ToolResult(
output: .object([
"query": .string(query),
"matchCount": .number(Double(tools.count)),
"tools": .array(toolsJSON),
]),
isError: false
)
} catch {
return ToolResult(
output: .object([
"error": .bool(true),
"message": .string(error.localizedDescription),
]),
isError: true
)
}
}
private func handleRegistryExecute(
args: [String: JSONValue],
envVars: [String: String]
) async -> ToolResult {
guard case .string(let toolId) = args["toolId"] else {
return ToolResult(
output: .object(["error": .bool(true), "message": .string("Missing 'toolId' parameter")]),
isError: true
)
}
let params = args["params"] ?? .object([:])
do {
let response = try await registry.executeByToolId(
toolId: toolId,
params: params,
env: envVars
)
if response.success {
return ToolResult(
output: .object([
"toolId": .string(toolId),
"executionTimeMs": .number(Double(response.executionTimeMs ?? 0)),
"output": response.output ?? .null,
]),
isError: false
)
} else {
return ToolResult(
output: .object([
"error": .bool(true),
"message": .string(response.error ?? "Tool execution failed"),
"toolId": .string(toolId),
]),
isError: true
)
}
} catch {
return ToolResult(
output: .object([
"error": .bool(true),
"message": .string(error.localizedDescription),
"toolId": .string(toolId),
]),
isError: true
)
}
}
private func handleDynamicTool(
meta: ToolMeta,
args: [String: JSONValue],
envVars: [String: String]
) async -> ToolResult {
do {
let response = try await registry.executeTool(
packageName: meta.packageName,
name: meta.name,
version: meta.version,
importUrl: meta.importUrl,
params: .object(args),
env: envVars
)
if response.success {
return ToolResult(
output: response.output ?? .null,
isError: false
)
} else {
return ToolResult(
output: .object([
"error": .bool(true),
"message": .string(response.error ?? "Tool execution failed"),
"toolId": .string(meta.toolId),
]),
isError: true
)
}
} catch {
return ToolResult(
output: .object([
"error": .bool(true),
"message": .string(error.localizedDescription),
"toolId": .string(meta.toolId),
]),
isError: true
)
}
}
/// Build chat messages from conversation history
private func buildChatMessages(
conversation: Conversation,
customPrompt: String?,
pinnedToolIds: [String]
) -> [ChatMessage] {
var messages: [ChatMessage] = []
// System prompt
let systemPrompt = SystemPromptBuilder.build(
customSystemPrompt: customPrompt,
pinnedToolIds: pinnedToolIds,
loadedTools: loadedTools
)
messages.append(.system(systemPrompt))
// Last 20 messages from conversation history
let sorted = conversation.sortedMessages
let recent = sorted.suffix(20)
for msg in recent {
switch msg.role {
case .user:
messages.append(.user(msg.content))
case .assistant:
let toolCalls = msg.toolCalls
if !toolCalls.isEmpty {
let chatToolCalls = toolCalls.map { tc in
ChatToolCall(
id: tc.toolCallId,
type: "function",
function: ChatToolCallFunction(
name: tc.toolName,
arguments: {
if let args = tc.args,
let data = try? JSONEncoder().encode(args) {
return String(data: data, encoding: .utf8) ?? "{}"
}
return "{}"
}()
)
)
}
messages.append(.assistant(content: msg.content, toolCalls: chatToolCalls))
} else {
messages.append(.assistant(content: msg.content, toolCalls: nil))
}
case .tool:
for tc in msg.toolCalls {
let outputJSON: String
if let output = tc.output,
let data = try? JSONEncoder().encode(output) {
outputJSON = String(data: data, encoding: .utf8) ?? "{}"
} else {
outputJSON = "{}"
}
messages.append(.toolResult(
toolCallId: tc.toolCallId,
name: tc.toolName,
content: outputJSON
))
}
case .system:
break
}
}
return messages
}
/// Build the OpenAI tools array from static + dynamic tools
private func buildToolsList() -> [ChatTool] {
var tools: [ChatTool] = []
// Static: registrySearch
tools.append(ChatTool(
function: ChatFunction(
name: "registrySearch",
description: "Search the TPMJS tool registry to find AI SDK tools. Use this to discover tools for any task. Returns toolIds that can be executed with registryExecute.",
parameters: JSONSchemaObject(
type: "object",
properties: [
"query": JSONSchemaProperty(
type: "string",
description: "Search query (keywords, tool names, descriptions)"
),
"limit": JSONSchemaProperty(
type: "number",
description: "Maximum number of results (1-20, default 5)",
minimum: 1,
maximum: 20
),
],
required: ["query"],
additionalProperties: false
)
)
))
// Static: registryExecute
tools.append(ChatTool(
function: ChatFunction(
name: "registryExecute",
description: "Execute a tool from the TPMJS registry. Use registrySearch first to find the toolId. Tools run in a secure sandbox.",
parameters: JSONSchemaObject(
type: "object",
properties: [
"toolId": JSONSchemaProperty(
type: "string",
description: "Tool identifier from registrySearch (format: 'package::name')"
),
"params": JSONSchemaProperty(
type: "object",
description: "Parameters to pass to the tool",
additionalProperties: .bool(true)
),
],
required: ["toolId", "params"],
additionalProperties: false
)
)
))
// Dynamic tools
for (_, meta) in loadedTools {
tools.append(meta.toChatTool())
}
return tools
}
}

View file

@ -0,0 +1,124 @@
import Foundation
import Security
/// Wrapper around macOS Keychain for storing API keys and env var values securely.
enum KeychainService {
private static let serviceName = "com.tpmjs.omega-mac"
/// Save or update a value in the Keychain
static func save(key: String, value: String) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.encodingFailed
}
// Check if item exists
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
]
let status = SecItemCopyMatching(query as CFDictionary, nil)
if status == errSecSuccess {
// Update existing
let attributes: [String: Any] = [
kSecValueData as String: data,
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainError.unhandledError(updateStatus)
}
} else if status == errSecItemNotFound {
// Add new
var addQuery = query
addQuery[kSecValueData as String] = data
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw KeychainError.unhandledError(addStatus)
}
} else {
throw KeychainError.unhandledError(status)
}
}
/// Retrieve a value from the Keychain
static func load(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
/// Delete a value from the Keychain
static func delete(key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unhandledError(status)
}
}
/// Load all stored keys (returns key names only, not values)
static func allKeys() -> [String] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let items = result as? [[String: Any]] else {
return []
}
return items.compactMap { $0[kSecAttrAccount as String] as? String }
}
/// Convenience: load all env vars as a dictionary
static func loadAllEnvVars(keyNames: [String]) -> [String: String] {
var result: [String: String] = [:]
for key in keyNames {
if let value = load(key: key) {
result[key] = value
}
}
return result
}
}
enum KeychainError: LocalizedError {
case encodingFailed
case unhandledError(OSStatus)
var errorDescription: String? {
switch self {
case .encodingFailed:
return "Failed to encode value for Keychain"
case .unhandledError(let status):
return "Keychain error: \(status)"
}
}
}

View file

@ -0,0 +1,141 @@
import Foundation
/// Actor that handles all communication with the OpenAI Chat Completions API.
/// Supports streaming via Server-Sent Events (SSE).
actor OpenAIService {
private let session: URLSession
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 300
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
/// Stream a chat completion, yielding parsed events as they arrive.
func streamCompletion(
apiKey: String,
model: String,
messages: [ChatMessage],
tools: [ChatTool]?
) -> AsyncThrowingStream<StreamParser.StreamEvent, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let request = try buildRequest(
apiKey: apiKey,
model: model,
messages: messages,
tools: tools,
stream: true
)
let (bytes, response) = try await session.bytes(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
continuation.yield(.error("Invalid response type"))
continuation.finish()
return
}
guard httpResponse.statusCode == 200 else {
// Try to read error body
var errorBody = ""
for try await line in bytes.lines {
errorBody += line
}
continuation.yield(.error("API error \(httpResponse.statusCode): \(errorBody)"))
continuation.finish()
return
}
// Track accumulated tool calls
var toolCallAccumulators: [Int: StreamParser.ToolCallAccumulator] = [:]
for try await line in bytes.lines {
let events = StreamParser.parseLine(line)
for event in events {
switch event {
case .toolCallStarted(let index, let id, let name):
toolCallAccumulators[index] = StreamParser.ToolCallAccumulator(
id: id,
name: name,
arguments: ""
)
continuation.yield(event)
case .toolCallArgumentDelta(let index, let delta):
toolCallAccumulators[index]?.arguments += delta
continuation.yield(event)
case .done:
// Emit completed tool calls
for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
let toolCall = ChatToolCall(
id: acc.id,
type: "function",
function: ChatToolCallFunction(
name: acc.name,
arguments: acc.arguments
)
)
continuation.yield(.toolCallComplete(toolCall))
}
continuation.yield(.done)
continuation.finish()
default:
continuation.yield(event)
}
}
}
// If we reach here without [DONE], still emit completed tool calls
if !toolCallAccumulators.isEmpty {
for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
let toolCall = ChatToolCall(
id: acc.id,
type: "function",
function: ChatToolCallFunction(
name: acc.name,
arguments: acc.arguments
)
)
continuation.yield(.toolCallComplete(toolCall))
}
}
continuation.finish()
} catch {
continuation.yield(.error(error.localizedDescription))
continuation.finish(throwing: error)
}
}
}
}
private func buildRequest(
apiKey: String,
model: String,
messages: [ChatMessage],
tools: [ChatTool]?,
stream: Bool
) throws -> URLRequest {
var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
request.httpMethod = "POST"
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ChatCompletionRequest(
model: model,
messages: messages,
tools: tools?.isEmpty == true ? nil : tools,
stream: stream,
maxTokens: 4096,
streamOptions: stream ? StreamOptions(includeUsage: true) : nil
)
request.httpBody = try JSONEncoder().encode(body)
return request
}
}

View file

@ -0,0 +1,209 @@
import Foundation
// MARK: - Request Types
struct ChatCompletionRequest: Encodable {
let model: String
let messages: [ChatMessage]
let tools: [ChatTool]?
let stream: Bool
let maxTokens: Int?
let streamOptions: StreamOptions?
enum CodingKeys: String, CodingKey {
case model, messages, tools, stream
case maxTokens = "max_tokens"
case streamOptions = "stream_options"
}
}
struct StreamOptions: Encodable {
let includeUsage: Bool
enum CodingKeys: String, CodingKey {
case includeUsage = "include_usage"
}
}
struct ChatMessage: Codable {
let role: String
let content: String?
let toolCalls: [ChatToolCall]?
let toolCallId: String?
let name: String?
enum CodingKeys: String, CodingKey {
case role, content, name
case toolCalls = "tool_calls"
case toolCallId = "tool_call_id"
}
static func system(_ content: String) -> ChatMessage {
ChatMessage(role: "system", content: content, toolCalls: nil, toolCallId: nil, name: nil)
}
static func user(_ content: String) -> ChatMessage {
ChatMessage(role: "user", content: content, toolCalls: nil, toolCallId: nil, name: nil)
}
static func assistant(content: String?, toolCalls: [ChatToolCall]?) -> ChatMessage {
ChatMessage(role: "assistant", content: content, toolCalls: toolCalls, toolCallId: nil, name: nil)
}
static func toolResult(toolCallId: String, name: String, content: String) -> ChatMessage {
ChatMessage(role: "tool", content: content, toolCalls: nil, toolCallId: toolCallId, name: name)
}
}
struct ChatTool: Encodable {
let type: String = "function"
let function: ChatFunction
}
struct ChatFunction: Encodable {
let name: String
let description: String
let parameters: JSONSchemaObject
}
struct JSONSchemaObject: Encodable {
let type: String
let properties: [String: JSONSchemaProperty]
let required: [String]?
let additionalProperties: Bool?
}
struct JSONSchemaProperty: Encodable {
let type: String
let description: String?
let minimum: Int?
let maximum: Int?
let additionalProperties: JSONSchemaAdditional?
init(type: String, description: String? = nil, minimum: Int? = nil, maximum: Int? = nil, additionalProperties: JSONSchemaAdditional? = nil) {
self.type = type
self.description = description
self.minimum = minimum
self.maximum = maximum
self.additionalProperties = additionalProperties
}
}
indirect enum JSONSchemaAdditional: Encodable {
case bool(Bool)
case typed(JSONSchemaProperty)
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .bool(let b): try container.encode(b)
case .typed(let p): try container.encode(p)
}
}
}
struct ChatToolCall: Codable, Identifiable {
var id: String
let type: String?
let function: ChatToolCallFunction?
var toolName: String { function?.name ?? "" }
var arguments: String { function?.arguments ?? "{}" }
var parsedArguments: [String: JSONValue] {
guard let data = arguments.data(using: .utf8),
let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
return [:]
}
return obj
}
}
struct ChatToolCallFunction: Codable {
let name: String?
let arguments: String?
}
// MARK: - Response Types (non-streaming)
struct ChatCompletionResponse: Decodable {
let id: String
let choices: [ChatChoice]
let usage: ChatUsage?
}
struct ChatChoice: Decodable {
let index: Int
let message: ChatResponseMessage
let finishReason: String?
enum CodingKeys: String, CodingKey {
case index, message
case finishReason = "finish_reason"
}
}
struct ChatResponseMessage: Decodable {
let role: String
let content: String?
let toolCalls: [ChatToolCall]?
enum CodingKeys: String, CodingKey {
case role, content
case toolCalls = "tool_calls"
}
}
struct ChatUsage: Decodable {
let promptTokens: Int?
let completionTokens: Int?
let totalTokens: Int?
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
// MARK: - Streaming Types
struct ChatCompletionChunk: Decodable {
let id: String?
let choices: [ChunkChoice]?
let usage: ChatUsage?
}
struct ChunkChoice: Decodable {
let index: Int?
let delta: ChunkDelta?
let finishReason: String?
enum CodingKeys: String, CodingKey {
case index, delta
case finishReason = "finish_reason"
}
}
struct ChunkDelta: Decodable {
let role: String?
let content: String?
let toolCalls: [ChunkToolCall]?
enum CodingKeys: String, CodingKey {
case role, content
case toolCalls = "tool_calls"
}
}
struct ChunkToolCall: Decodable {
let index: Int?
let id: String?
let type: String?
let function: ChunkToolCallFunction?
}
struct ChunkToolCallFunction: Decodable {
let name: String?
let arguments: String?
}

View file

@ -0,0 +1,105 @@
import Foundation
/// Parses Server-Sent Events (SSE) from OpenAI's streaming API.
/// Handles `data: {...}` lines and `data: [DONE]` termination.
struct StreamParser {
/// Accumulated tool call state during streaming
struct ToolCallAccumulator {
var id: String = ""
var name: String = ""
var arguments: String = ""
}
/// Result of parsing the stream - yields content deltas and complete tool calls
enum StreamEvent: Sendable {
case contentDelta(String)
case toolCallStarted(index: Int, id: String, name: String)
case toolCallArgumentDelta(index: Int, delta: String)
case toolCallComplete(ChatToolCall)
case usage(inputTokens: Int, outputTokens: Int)
case done
case error(String)
}
/// Parse a single SSE line and return events
static func parseLine(_ line: String) -> [StreamEvent] {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
// Skip empty lines and comments
guard !trimmed.isEmpty, !trimmed.hasPrefix(":") else {
return []
}
// Must start with "data: "
guard trimmed.hasPrefix("data: ") else {
return []
}
let payload = String(trimmed.dropFirst(6))
// Check for stream end
if payload == "[DONE]" {
return [.done]
}
// Parse JSON chunk
guard let data = payload.data(using: .utf8) else {
return [.error("Invalid UTF-8 in SSE payload")]
}
do {
let chunk = try JSONDecoder().decode(ChatCompletionChunk.self, from: data)
return processChunk(chunk)
} catch {
return [.error("Failed to parse chunk: \(error.localizedDescription)")]
}
}
private static func processChunk(_ chunk: ChatCompletionChunk) -> [StreamEvent] {
var events: [StreamEvent] = []
if let choices = chunk.choices {
for choice in choices {
guard let delta = choice.delta else { continue }
// Content delta
if let content = delta.content, !content.isEmpty {
events.append(.contentDelta(content))
}
// Tool calls
if let toolCalls = delta.toolCalls {
for tc in toolCalls {
let idx = tc.index ?? 0
if let id = tc.id, !id.isEmpty {
events.append(.toolCallStarted(
index: idx,
id: id,
name: tc.function?.name ?? ""
))
}
if let args = tc.function?.arguments, !args.isEmpty {
events.append(.toolCallArgumentDelta(index: idx, delta: args))
}
}
}
// Finish reason
if choice.finishReason == "stop" || choice.finishReason == "tool_calls" {
// Will be handled by [DONE]
}
}
}
// Usage info (sometimes included in last chunk)
if let usage = chunk.usage {
events.append(.usage(
inputTokens: usage.promptTokens ?? 0,
outputTokens: usage.completionTokens ?? 0
))
}
return events
}
}

View file

@ -0,0 +1,156 @@
import Foundation
/// Actor that handles communication with the TPMJS tool registry API
/// and the remote executor service.
actor TPMJSRegistryService {
private let session: URLSession
private let registryBaseURL: String
private let executorBaseURL: String
init(
registryBaseURL: String = "https://tpmjs.com",
executorBaseURL: String = "https://executor.tpmjs.com"
) {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
self.session = URLSession(configuration: config)
self.registryBaseURL = registryBaseURL
self.executorBaseURL = executorBaseURL
}
// MARK: - Search
/// Search for tools matching a query using BM25
func searchTools(query: String, limit: Int = 10) async throws -> [ToolMeta] {
var components = URLComponents(string: "\(registryBaseURL)/api/tools/search")!
components.queryItems = [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "limit", value: String(limit)),
]
guard let url = components.url else {
throw TPMJSError.invalidURL
}
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return []
}
let searchResponse = try JSONDecoder().decode(TPMJSSearchResponse.self, from: data)
let tools = searchResponse.results?.tools ?? []
return tools.map { tool in
ToolMeta(
toolId: "\(tool.package.npmPackageName)::\(tool.name)",
packageName: tool.package.npmPackageName,
name: tool.name,
description: tool.description ?? "Tool: \(tool.name)",
version: tool.package.npmVersion,
importUrl: tool.importUrl ?? "https://esm.sh/\(tool.package.npmPackageName)@\(tool.package.npmVersion)",
inputSchema: tool.inputSchema,
env: tool.package.env
)
}
}
// MARK: - Execute via Executor
/// Execute a tool via the TPMJS remote sandbox executor
func executeTool(
packageName: String,
name: String,
version: String,
importUrl: String,
params: JSONValue,
env: [String: String]
) async throws -> TPMJSExecuteResponse {
guard let url = URL(string: "\(executorBaseURL)/execute-tool") else {
throw TPMJSError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let body = TPMJSExecuteRequest(
packageName: packageName,
name: name,
version: version,
importUrl: importUrl,
params: params,
env: env
)
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
throw TPMJSError.httpError(statusCode)
}
return try JSONDecoder().decode(TPMJSExecuteResponse.self, from: data)
}
// MARK: - Registry Execute (uses search first to find metadata)
/// Execute a tool by its toolId (package::name format).
/// Fetches metadata first via search, then executes via executor.
func executeByToolId(
toolId: String,
params: JSONValue,
env: [String: String]
) async throws -> TPMJSExecuteResponse {
// Parse toolId format: "package::name"
guard let separatorIndex = toolId.range(of: "::", options: .backwards) else {
throw TPMJSError.invalidToolId(toolId)
}
let packageName = String(toolId[toolId.startIndex..<separatorIndex.lowerBound])
let name = String(toolId[separatorIndex.upperBound...])
guard !packageName.isEmpty, !name.isEmpty else {
throw TPMJSError.invalidToolId(toolId)
}
// Search for the tool to get version metadata
let searchResults = try await searchTools(query: name, limit: 10)
guard let toolMeta = searchResults.first(where: {
$0.packageName == packageName && $0.name == name
}) else {
throw TPMJSError.toolNotFound(toolId)
}
return try await executeTool(
packageName: toolMeta.packageName,
name: toolMeta.name,
version: toolMeta.version,
importUrl: toolMeta.importUrl,
params: params,
env: env
)
}
}
// MARK: - Errors
enum TPMJSError: LocalizedError {
case invalidURL
case httpError(Int)
case invalidToolId(String)
case toolNotFound(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .httpError(let code): return "HTTP error: \(code)"
case .invalidToolId(let id): return "Invalid tool ID format: \(id). Expected 'package::name'"
case .toolNotFound(let id): return "Tool not found: \(id). Try using registrySearch to find available tools."
}
}
}

View file

@ -0,0 +1,123 @@
import Foundation
// MARK: - Search API Types
struct TPMJSSearchResponse: Decodable {
let results: TPMJSSearchResults?
}
struct TPMJSSearchResults: Decodable {
let tools: [TPMJSToolResult]?
}
struct TPMJSToolResult: Decodable {
let name: String
let description: String?
let inputSchema: JSONValue?
let qualityScore: Double?
let executionHealth: String?
let importUrl: String?
let package: TPMJSPackageInfo
enum CodingKeys: String, CodingKey {
case name, description, inputSchema, qualityScore, executionHealth, importUrl
case package = "package"
}
}
struct TPMJSPackageInfo: Decodable {
let npmPackageName: String
let npmVersion: String
let category: String?
let env: [String]?
}
// MARK: - Executor API Types
struct TPMJSExecuteRequest: Encodable {
let packageName: String
let name: String
let version: String
let importUrl: String
let params: JSONValue
let env: [String: String]
}
struct TPMJSExecuteResponse: Decodable, Sendable {
let success: Bool
let output: JSONValue?
let error: String?
let executionTimeMs: Int?
}
// MARK: - Tool Metadata (internal tracking)
struct ToolMeta: Sendable {
let toolId: String
let packageName: String
let name: String
let description: String
let version: String
let importUrl: String
let inputSchema: JSONValue?
let env: [String]?
/// Convert to an OpenAI function tool definition
func toChatTool() -> ChatTool {
let properties: [String: JSONSchemaProperty]
let required: [String]?
if case .object(let schemaObj) = inputSchema {
// Extract properties from schema
var props: [String: JSONSchemaProperty] = [:]
var reqs: [String] = []
if case .object(let propsObj) = schemaObj["properties"] {
for (key, value) in propsObj {
if case .object(let propDef) = value {
let typeStr: String
if case .string(let t) = propDef["type"] {
typeStr = t
} else {
typeStr = "string"
}
let desc: String?
if case .string(let d) = propDef["description"] {
desc = d
} else {
desc = nil
}
props[key] = JSONSchemaProperty(type: typeStr, description: desc)
}
}
}
if case .array(let reqArr) = schemaObj["required"] {
for item in reqArr {
if case .string(let s) = item {
reqs.append(s)
}
}
}
properties = props
required = reqs.isEmpty ? nil : reqs
} else {
properties = [:]
required = nil
}
return ChatTool(
function: ChatFunction(
name: sanitizeToolName(toolId),
description: description,
parameters: JSONSchemaObject(
type: "object",
properties: properties,
required: required,
additionalProperties: true
)
)
)
}
}

View file

@ -0,0 +1,40 @@
import Foundation
/// Sanitize a tool ID to be a valid OpenAI function name.
/// Port of the web's sanitizeToolName logic.
/// OpenAI requires tool names to be <= 64 characters and match [a-zA-Z0-9_-].
func sanitizeToolName(_ name: String) -> String {
var sanitized = name
.replacingOccurrences(of: "@", with: "")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "-", with: "_")
.replacingOccurrences(of: "::", with: "_")
// Remove any remaining invalid characters
sanitized = String(sanitized.unicodeScalars.filter { scalar in
CharacterSet.alphanumerics.contains(scalar) || scalar == "_"
})
// OpenAI API requires tool names <= 64 characters
if sanitized.count <= 64 {
return sanitized
}
// Truncate but try to keep the meaningful part (tool name at the end)
let last64 = String(sanitized.suffix(64))
if let first = last64.first, first.isLetter {
return last64
}
return String(sanitized.prefix(64))
}
/// Reverse lookup: find the original toolId from a sanitized name
/// by checking against loaded tool metadata.
func findToolId(sanitizedName: String, in tools: [String: ToolMeta]) -> String? {
for (_, meta) in tools {
if sanitizeToolName(meta.toolId) == sanitizedName {
return meta.toolId
}
}
return nil
}

View file

@ -0,0 +1,134 @@
import Foundation
/// Builds the system prompt for the Omega agent.
/// Port of the web's buildSystemPrompt logic from system-prompt.ts.
enum SystemPromptBuilder {
static let basePrompt = """
You are Omega, an AI assistant powered by the TPMJS tool registry - a collection of 1M+ AI-ready tools.
## Core Tools
You have access to two powerful meta-tools that give you access to the entire TPMJS registry:
1. **registrySearch** - Search for tools by keyword, category, or description
2. **registryExecute** - Execute any tool by its toolId
These tools are importable by users into their own AI agents via:
```typescript
import { registrySearchTool } from '@tpmjs/registry-search';
import { registryExecuteTool } from '@tpmjs/registry-execute';
```
## How It Works
1. When the user asks for something, relevant tools are automatically discovered and loaded
2. You can also explicitly search using registrySearch
3. Once tools are found, you have two options:
- Use registryExecute with the toolId to execute any tool
- Call dynamically loaded tools directly by their sanitized name
## Workflow Examples
### Example 1: User wants weather data
1. Call registrySearch({ query: "weather api" })
2. Review the results (toolIds like "@weather-api/sdk::getWeather")
3. Call registryExecute({ toolId: "@weather-api/sdk::getWeather", params: { city: "Tokyo" } })
4. Explain the result to the user
### Example 2: Tool already loaded
If you see a tool like "weatherapi_sdk_getWeather" in the dynamically loaded tools list, call it directly instead of using registryExecute.
## Best Practices
- **Search first** - If you don't see a relevant tool loaded, use registrySearch
- **Execute don't describe** - Actually call tools to get real results
- **Handle errors** - If a tool fails, explain and try an alternative
- **Be efficient** - If a tool is already loaded, call it directly
## Response Style
- Keep responses concise and helpful
- Present tool outputs in a clear, readable format
- Tell the user which tool you used
- Offer to do more if the user might need it
Remember: Your value is in EXECUTING tools to get real results, not describing what tools could do.
"""
/// Build the complete system prompt with tool listings and user customizations
static func build(
customSystemPrompt: String?,
pinnedToolIds: [String],
loadedTools: [String: ToolMeta]
) -> String {
var parts: [String] = [basePrompt]
// Pinned tools
if !pinnedToolIds.isEmpty {
let pinned = pinnedToolIds.map { "- Tool ID: \($0)" }.joined(separator: "\n")
parts.append("""
## Pinned Tools
The user has pinned the following tools as favorites. Consider using these first when they match the task:
\(pinned)
""")
}
// Custom system prompt
if let custom = customSystemPrompt, !custom.isEmpty {
parts.append("""
## User Instructions
The user has provided the following custom instructions:
\(custom)
""")
}
// Static tools
let staticToolsList = """
- registrySearch: Search the TPMJS registry to find AI SDK tools by keyword. Returns toolIds for registryExecute.
- registryExecute: Execute any tool from the TPMJS registry by toolId. Use registrySearch first to find tools.
"""
parts.append("""
## Static Tools (Always Available)
These tools let you access the entire TPMJS registry of 1M+ tools:
\(staticToolsList)
""")
// Dynamic tools
let dynamicToolsList: String
if loadedTools.isEmpty {
dynamicToolsList = "No tools loaded yet. Use registrySearch to find tools, or they will be auto-loaded based on your requests."
} else {
dynamicToolsList = loadedTools.map { (name, meta) in
"- \(name): \(meta.description)"
}.joined(separator: "\n")
}
parts.append("""
## Dynamically Loaded Tools
These tools have been discovered and loaded for this conversation. Call them directly:
\(dynamicToolsList)
""")
// Usage instructions
parts.append("""
## How to Use Tools
1. **To find a tool**: Use registrySearch with a keyword (e.g., "weather", "web scraping", "database")
2. **To execute a found tool**: Use registryExecute with the toolId returned from search
3. **Direct execution**: If a tool is already loaded above, call it directly by name
Remember: Your value is in EXECUTING tools to get real results, not just describing what tools could do.
""")
return parts.joined(separator: "\n\n")
}
}

View file

@ -0,0 +1,119 @@
import SwiftUI
struct ChatInputBar: View {
@Binding var text: String
let isStreaming: Bool
let onSend: () -> Void
var body: some View {
VStack(spacing: 4) {
Divider()
HStack(alignment: .bottom, spacing: 8) {
SendableTextEditor(text: $text, onSend: {
if canSend { onSend() }
})
.font(.body)
.frame(minHeight: 40, maxHeight: 160)
.fixedSize(horizontal: false, vertical: true)
Button(action: onSend) {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(canSend ? Color.accentColor : Color.secondary)
}
.buttonStyle(.borderless)
.disabled(!canSend)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
Text("Enter to send, Shift+Enter for new line")
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.bottom, 4)
}
.background(.background)
}
private var canSend: Bool {
!isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
/// NSTextView-backed editor that intercepts Return (send) vs Shift+Return (newline)
struct SendableTextEditor: NSViewRepresentable {
@Binding var text: String
let onSend: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
let textView = SendableNSTextView()
textView.delegate = context.coordinator
textView.sendAction = onSend
textView.isRichText = false
textView.allowsUndo = true
textView.font = .systemFont(ofSize: NSFont.systemFontSize)
textView.textColor = .labelColor
textView.drawsBackground = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.textContainerInset = NSSize(width: 8, height: 8)
textView.textContainer?.widthTracksTextView = true
textView.autoresizingMask = [.width]
scrollView.documentView = textView
scrollView.hasVerticalScroller = false
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.contentView.drawsBackground = false
// Style the scroll view as a rounded input field
scrollView.wantsLayer = true
scrollView.layer?.cornerRadius = 10
scrollView.layer?.backgroundColor = NSColor.quaternaryLabelColor.withAlphaComponent(0.3).cgColor
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
textView.string = text
}
}
class Coordinator: NSObject, NSTextViewDelegate {
var parent: SendableTextEditor
init(_ parent: SendableTextEditor) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
parent.text = textView.string
}
}
}
/// Custom NSTextView that sends on Return and inserts newline on Shift+Return
class SendableNSTextView: NSTextView {
var sendAction: (() -> Void)?
override func keyDown(with event: NSEvent) {
if event.keyCode == 36 { // Return key
if event.modifierFlags.contains(.shift) {
super.keyDown(with: event) // Insert newline
} else {
sendAction?()
}
return
}
super.keyDown(with: event)
}
}

View file

@ -0,0 +1,129 @@
import AppKit
import SwiftData
import SwiftUI
struct ChatView: View {
@Bindable var conversation: Conversation
var orchestrator: ChatOrchestrator
@Environment(\.modelContext) private var modelContext
@State private var inputText: String = ""
@State private var showCopied: Bool = false
var body: some View {
VStack(spacing: 0) {
// Messages
MessageList(
conversation: conversation,
streamingContent: orchestrator.streamingContent,
isStreaming: orchestrator.isStreaming,
liveToolCalls: orchestrator.liveToolCalls
)
// Error banner
if let error = orchestrator.error {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text(error)
.font(.callout)
.foregroundStyle(.red)
Spacer()
Button("Dismiss") {
orchestrator.error = nil
}
.buttonStyle(.borderless)
.font(.callout)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.red.opacity(0.1))
}
// Input bar
ChatInputBar(
text: $inputText,
isStreaming: orchestrator.isStreaming,
onSend: sendMessage
)
}
.navigationTitle(conversation.displayTitle)
.toolbar {
ToolbarItem(placement: .automatic) {
if orchestrator.isStreaming {
ProgressView()
.controlSize(.small)
}
}
ToolbarItem(placement: .automatic) {
Button(action: copyConversationAsJSON) {
Label(showCopied ? "Copied!" : "Copy JSON",
systemImage: showCopied ? "checkmark" : "doc.on.doc")
}
.help("Copy conversation as JSON (Cmd+Shift+C)")
.keyboardShortcut("c", modifiers: [.command, .shift])
}
}
}
private func sendMessage() {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
inputText = ""
Task {
await orchestrator.sendMessage(text, conversation: conversation, modelContext: modelContext)
}
}
private func copyConversationAsJSON() {
let messages = conversation.sortedMessages.map { msg -> [String: Any] in
var dict: [String: Any] = [
"role": msg.role.rawValue.lowercased(),
"content": msg.content,
"createdAt": ISO8601DateFormatter().string(from: msg.createdAt),
]
if let input = msg.inputTokens { dict["inputTokens"] = input }
if let output = msg.outputTokens { dict["outputTokens"] = output }
let toolCalls = msg.toolCalls
if !toolCalls.isEmpty {
dict["toolCalls"] = toolCalls.map { tc -> [String: Any] in
var tcDict: [String: Any] = [
"toolCallId": tc.toolCallId,
"toolName": tc.toolName,
]
if let args = tc.args,
let data = try? JSONEncoder().encode(args),
let json = try? JSONSerialization.jsonObject(with: data) {
tcDict["args"] = json
}
if let output = tc.output,
let data = try? JSONEncoder().encode(output),
let json = try? JSONSerialization.jsonObject(with: data) {
tcDict["output"] = json
}
return tcDict
}
}
return dict
}
let payload: [String: Any] = [
"conversationId": conversation.id.uuidString,
"title": conversation.displayTitle,
"createdAt": ISO8601DateFormatter().string(from: conversation.createdAt),
"messages": messages,
]
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]),
let json = String(data: data, encoding: .utf8) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(json, forType: .string)
showCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
showCopied = false
}
}
}
}

View file

@ -0,0 +1,69 @@
import SwiftUI
struct MessageBubble: View {
let message: Message
var body: some View {
switch message.role {
case .user:
userBubble
case .assistant:
assistantBubble
case .tool:
toolResultsBubble
case .system:
EmptyView()
}
}
private var userBubble: some View {
HStack(alignment: .top) {
Spacer(minLength: 60)
Text(message.content)
.font(.body)
.foregroundStyle(.white)
.padding(12)
.background(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 12))
.textSelection(.enabled)
}
.padding(.horizontal, 16)
}
private var assistantBubble: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
if !message.content.isEmpty {
MarkdownView(content: message.content)
}
// Token usage
if let input = message.inputTokens, let output = message.outputTokens {
Text("In: \(input) | Out: \(output)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.tertiary)
.padding(.top, 4)
}
}
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.textSelection(.enabled)
Spacer(minLength: 60)
}
.padding(.horizontal, 16)
}
private var toolResultsBubble: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 8) {
ForEach(message.toolCalls) { tc in
JSONToolResultView(toolCallData: tc)
}
}
Spacer(minLength: 60)
}
.padding(.horizontal, 16)
}
}

View file

@ -0,0 +1,95 @@
import SwiftUI
struct MessageList: View {
let conversation: Conversation
let streamingContent: String
let isStreaming: Bool
let liveToolCalls: [LiveToolCall]
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(conversation.sortedMessages) { message in
MessageBubble(message: message)
.id(message.id)
}
// Live tool calls
ForEach(isStreaming ? liveToolCalls : []) { tc in
ToolCallView(toolCall: tc)
.id("live-tc-\(tc.id)")
}
// Streaming content
if isStreaming && !streamingContent.isEmpty {
HStack(alignment: .top) {
assistantBubble(content: streamingContent, isStreaming: true)
Spacer(minLength: 60)
}
.padding(.horizontal, 16)
.id("streaming")
}
// Thinking indicator
if isStreaming && streamingContent.isEmpty && liveToolCalls.isEmpty {
HStack {
StreamingIndicator()
Spacer()
}
.padding(.horizontal, 16)
.id("thinking")
}
// Bottom spacer for scroll padding
Color.clear.frame(height: 8)
.id("bottom")
}
.padding(.vertical, 12)
}
.onChange(of: streamingContent) {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: conversation.messages.count) {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
.onChange(of: liveToolCalls.count) {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
}
private func assistantBubble(content: String, isStreaming: Bool) -> some View {
VStack(alignment: .leading, spacing: 4) {
MarkdownView(content: content)
if isStreaming {
Rectangle()
.fill(Color.accentColor)
.frame(width: 2, height: 16)
.opacity(0.8)
.modifier(PulseAnimation())
}
}
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
private struct PulseAnimation: ViewModifier {
@State private var isAnimating = false
func body(content: Content) -> some View {
content
.opacity(isAnimating ? 0.3 : 1.0)
.animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: isAnimating)
.onAppear { isAnimating = true }
}
}

View file

@ -0,0 +1,24 @@
import SwiftUI
struct StreamingIndicator: View {
@State private var dotCount = 0
private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
var body: some View {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.foregroundStyle(Color.accentColor)
.font(.caption)
Text("Omega is thinking" + String(repeating: ".", count: dotCount))
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.onReceive(timer) { _ in
dotCount = (dotCount + 1) % 4
}
}
}

View file

@ -0,0 +1,39 @@
import SwiftData
import SwiftUI
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@State private var selectedConversation: Conversation?
@State private var orchestrator = ChatOrchestrator()
var body: some View {
NavigationSplitView {
SidebarView(
selectedConversation: $selectedConversation,
onNewConversation: createNewConversation
)
.navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340)
} detail: {
if let conversation = selectedConversation {
ChatView(conversation: conversation, orchestrator: orchestrator)
} else {
EmptyStateView(onNewConversation: createNewConversation)
}
}
.navigationSplitViewStyle(.balanced)
.onChange(of: selectedConversation) {
orchestrator.resetConversation()
}
.onReceive(NotificationCenter.default.publisher(for: .newConversation)) { _ in
createNewConversation()
}
}
private func createNewConversation() {
let conversation = Conversation()
modelContext.insert(conversation)
try? modelContext.save()
selectedConversation = conversation
orchestrator.resetConversation()
}
}

View file

@ -0,0 +1,129 @@
import SwiftData
import SwiftUI
struct APIKeySettings: View {
@State private var apiKey: String = ""
@State private var hasKey: Bool = false
@State private var showKey: Bool = false
@State private var saveStatus: String?
@Query private var settingsArray: [UserSettings]
@Environment(\.modelContext) private var modelContext
private var settings: UserSettings {
if let existing = settingsArray.first {
return existing
}
let s = UserSettings()
modelContext.insert(s)
try? modelContext.save()
return s
}
@State private var selectedModel: String = "gpt-4.1-mini"
private let availableModels = [
"gpt-4.1-mini",
"gpt-4.1",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"o4-mini",
]
var body: some View {
Form {
Section("OpenAI API Key") {
HStack {
if showKey {
TextField("sk-...", text: $apiKey)
.font(.system(.body, design: .monospaced))
} else {
SecureField("sk-...", text: $apiKey)
.font(.system(.body, design: .monospaced))
}
Button {
showKey.toggle()
} label: {
Image(systemName: showKey ? "eye.slash" : "eye")
}
.buttonStyle(.borderless)
}
HStack {
Button("Save Key") {
saveAPIKey()
}
.disabled(apiKey.isEmpty)
if hasKey {
Button("Remove Key", role: .destructive) {
removeAPIKey()
}
}
Spacer()
if let status = saveStatus {
Text(status)
.font(.caption)
.foregroundStyle(status.contains("Error") ? .red : .green)
}
}
if hasKey {
Label("API key is stored securely in Keychain", systemImage: "lock.shield")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Model") {
Picker("Model", selection: $selectedModel) {
ForEach(availableModels, id: \.self) { model in
Text(model).tag(model)
}
}
.onChange(of: selectedModel) { _, newValue in
settings.selectedModel = newValue
try? modelContext.save()
}
}
}
.formStyle(.grouped)
.padding()
.onAppear {
hasKey = KeychainService.load(key: "OPENAI_API_KEY") != nil
selectedModel = settings.selectedModel
}
}
private func saveAPIKey() {
do {
try KeychainService.save(key: "OPENAI_API_KEY", value: apiKey)
hasKey = true
apiKey = ""
saveStatus = "Saved"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
saveStatus = nil
}
} catch {
saveStatus = "Error: \(error.localizedDescription)"
}
}
private func removeAPIKey() {
do {
try KeychainService.delete(key: "OPENAI_API_KEY")
hasKey = false
apiKey = ""
saveStatus = "Removed"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
saveStatus = nil
}
} catch {
saveStatus = "Error: \(error.localizedDescription)"
}
}
}

View file

@ -0,0 +1,131 @@
import SwiftData
import SwiftUI
struct EnvVarsSettings: View {
@Query(sort: \EnvVar.keyName) private var envVars: [EnvVar]
@Environment(\.modelContext) private var modelContext
@State private var newKeyName: String = ""
@State private var newKeyValue: String = ""
@State private var errorMessage: String?
var body: some View {
Form {
Section("Stored Environment Variables") {
if envVars.isEmpty {
Text("No environment variables configured.")
.font(.callout)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
} else {
ForEach(envVars) { envVar in
HStack {
VStack(alignment: .leading) {
Text(envVar.keyName)
.font(.system(.body, design: .monospaced))
Text("....\(envVar.valueHint)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button(role: .destructive) {
deleteEnvVar(envVar)
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
}
.buttonStyle(.borderless)
}
}
}
}
Section("Add New") {
TextField("Key name (e.g., WEATHER_API_KEY)", text: $newKeyName)
.font(.system(.body, design: .monospaced))
SecureField("Value", text: $newKeyValue)
.font(.system(.body, design: .monospaced))
HStack {
Button("Add") {
addEnvVar()
}
.disabled(newKeyName.isEmpty || newKeyValue.isEmpty)
if let error = errorMessage {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
}
Section {
Label(
"Values are stored in macOS Keychain. Only key names are visible in the app.",
systemImage: "lock.shield"
)
.font(.caption)
.foregroundStyle(.secondary)
Label(
"All environment variables are passed to tool executions automatically.",
systemImage: "info.circle"
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
}
private func addEnvVar() {
let name = newKeyName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let value = newKeyValue
guard !name.isEmpty, !value.isEmpty else { return }
// Check for duplicates
if envVars.contains(where: { $0.keyName == name }) {
// Update existing
do {
try KeychainService.save(key: name, value: value)
if let existing = envVars.first(where: { $0.keyName == name }) {
existing.valueHint = String(value.suffix(4))
}
try? modelContext.save()
newKeyName = ""
newKeyValue = ""
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
return
}
do {
try KeychainService.save(key: name, value: value)
let hint = String(value.suffix(4))
let envVar = EnvVar(keyName: name, valueHint: hint)
modelContext.insert(envVar)
try? modelContext.save()
newKeyName = ""
newKeyValue = ""
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
private func deleteEnvVar(_ envVar: EnvVar) {
try? KeychainService.delete(key: envVar.keyName)
modelContext.delete(envVar)
try? modelContext.save()
}
}

View file

@ -0,0 +1,23 @@
import SwiftUI
struct SettingsView: View {
var body: some View {
TabView {
APIKeySettings()
.tabItem {
Label("API Key", systemImage: "key")
}
EnvVarsSettings()
.tabItem {
Label("Environment", systemImage: "server.rack")
}
SystemPromptSettings()
.tabItem {
Label("System Prompt", systemImage: "text.bubble")
}
}
.frame(width: 520, height: 420)
}
}

View file

@ -0,0 +1,73 @@
import SwiftData
import SwiftUI
struct SystemPromptSettings: View {
@Query private var settingsArray: [UserSettings]
@Environment(\.modelContext) private var modelContext
@State private var promptText: String = ""
@State private var saved: Bool = false
private var settings: UserSettings {
if let existing = settingsArray.first {
return existing
}
let s = UserSettings()
modelContext.insert(s)
try? modelContext.save()
return s
}
var body: some View {
Form {
Section("Custom System Prompt") {
TextEditor(text: $promptText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 200)
.scrollContentBackground(.hidden)
.padding(4)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
HStack {
Button("Save") {
settings.systemPrompt = promptText.isEmpty ? nil : promptText
try? modelContext.save()
saved = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
saved = false
}
}
Button("Reset to Default") {
promptText = ""
settings.systemPrompt = nil
try? modelContext.save()
}
Spacer()
if saved {
Text("Saved")
.font(.caption)
.foregroundStyle(.green)
}
}
}
Section {
Label(
"Custom instructions are appended to the default Omega system prompt. Leave empty to use the default.",
systemImage: "info.circle"
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()
.onAppear {
promptText = settings.systemPrompt ?? ""
}
}
}

View file

@ -0,0 +1,52 @@
import SwiftUI
struct EmptyStateView: View {
let onNewConversation: () -> Void
var body: some View {
VStack(spacing: 20) {
Image(systemName: "star.circle.fill")
.font(.system(size: 56))
.foregroundStyle(Color.accentColor)
.symbolEffect(.pulse, options: .repeating)
Text("Omega")
.font(.largeTitle.bold())
Text("AI assistant powered by 1M+ tools from the TPMJS registry")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 400)
Button(action: onNewConversation) {
Label("New Conversation", systemImage: "plus")
.font(.headline)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.keyboardShortcut("n", modifiers: .command)
VStack(alignment: .leading, spacing: 8) {
featureRow(icon: "magnifyingglass", text: "Search 1M+ tools by keyword")
featureRow(icon: "play.circle", text: "Execute tools in a secure sandbox")
featureRow(icon: "bolt", text: "Auto-discovers relevant tools")
featureRow(icon: "key", text: "Securely store API keys in Keychain")
}
.padding(.top, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
}
private func featureRow(icon: String, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.frame(width: 20)
.foregroundStyle(Color.accentColor)
Text(text)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}

View file

@ -0,0 +1,79 @@
import MarkdownUI
import SwiftUI
/// Renders markdown content from assistant responses using MarkdownUI.
struct MarkdownView: View {
let content: String
var body: some View {
Markdown(content)
.markdownTheme(.omega)
.textSelection(.enabled)
}
}
// MARK: - Custom Markdown Theme
extension MarkdownUI.Theme {
static let omega = Theme()
.text {
ForegroundColor(.primary)
FontSize(14)
}
.code {
FontFamilyVariant(.monospaced)
FontSize(12)
ForegroundColor(.secondary)
}
.codeBlock { configuration in
configuration.label
.markdownTextStyle {
FontFamilyVariant(.monospaced)
FontSize(12)
ForegroundColor(.secondary)
}
.padding(10)
.background(Color.black.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.link {
ForegroundColor(.accentColor)
}
.heading1 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.bold)
FontSize(20)
}
.markdownMargin(top: 16, bottom: 8)
}
.heading2 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.semibold)
FontSize(17)
}
.markdownMargin(top: 12, bottom: 6)
}
.heading3 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.semibold)
FontSize(15)
}
.markdownMargin(top: 10, bottom: 4)
}
.blockquote { configuration in
HStack(spacing: 0) {
Rectangle()
.fill(Color.accentColor.opacity(0.4))
.frame(width: 3)
configuration.label
.markdownTextStyle {
ForegroundColor(.secondary)
FontSize(13)
}
.padding(.leading, 10)
}
}
}

View file

@ -0,0 +1,40 @@
import SwiftUI
struct ConversationRow: View {
let conversation: Conversation
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(conversation.displayTitle)
.font(.system(.body, design: .default))
.lineLimit(1)
.foregroundStyle(.primary)
Text(timeAgo(conversation.updatedAt))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
let count = conversation.messages.count
if count > 0 {
Text("\(count)")
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.quaternary)
.clipShape(Capsule())
}
}
.padding(.vertical, 2)
}
private func timeAgo(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}

View file

@ -0,0 +1,52 @@
import SwiftData
import SwiftUI
struct SidebarView: View {
@Binding var selectedConversation: Conversation?
let onNewConversation: () -> Void
@Query(sort: \Conversation.updatedAt, order: .reverse)
private var conversations: [Conversation]
@Environment(\.modelContext) private var modelContext
var body: some View {
List(selection: $selectedConversation) {
ForEach(conversations) { conversation in
ConversationRow(conversation: conversation)
.tag(conversation)
.contextMenu {
Button("Delete", role: .destructive) {
deleteConversation(conversation)
}
}
}
}
.listStyle(.sidebar)
.toolbar {
ToolbarItem(placement: .automatic) {
Button(action: onNewConversation) {
Image(systemName: "plus")
}
.help("New Conversation (Cmd+N)")
}
}
.overlay {
if conversations.isEmpty {
ContentUnavailableView {
Label("No Conversations", systemImage: "bubble.left.and.bubble.right")
} description: {
Text("Press Cmd+N to start a new conversation")
}
}
}
}
private func deleteConversation(_ conversation: Conversation) {
if selectedConversation == conversation {
selectedConversation = nil
}
modelContext.delete(conversation)
try? modelContext.save()
}
}

View file

@ -0,0 +1,98 @@
import SwiftUI
/// Displays a persisted tool call result from a ToolCallData record.
/// Collapsible card with monospaced JSON input/output.
struct JSONToolResultView: View {
let toolCallData: ToolCallData
@State private var isExpanded: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 8) {
Image(systemName: isError ? "xmark.circle.fill" : "checkmark.circle.fill")
.foregroundStyle(isError ? .red : .green)
.font(.caption)
Text(toolCallData.toolName)
.font(.system(.caption, design: .monospaced).bold())
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
if isExpanded {
Divider()
.padding(.horizontal, 10)
VStack(alignment: .leading, spacing: 8) {
// Input
if let args = toolCallData.args {
VStack(alignment: .leading, spacing: 4) {
Text("INPUT")
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(.tertiary)
Text(args.prettyString)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(6)
.background(.black.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
// Output
if let output = toolCallData.output {
VStack(alignment: .leading, spacing: 4) {
Text("OUTPUT")
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(.tertiary)
Text(output.prettyString)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(6)
.background(.black.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 4))
.lineLimit(20)
}
}
}
.padding(10)
}
}
.background(.quaternary.opacity(0.3))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isError ? .red.opacity(0.2) : .green.opacity(0.15), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var isError: Bool {
if case .object(let obj) = toolCallData.output,
case .bool(true) = obj["error"] {
return true
}
return false
}
}

View file

@ -0,0 +1,119 @@
import SwiftUI
struct ToolCallView: View {
let toolCall: LiveToolCall
var body: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
statusIcon
Text(toolCall.toolName)
.font(.system(.callout, design: .monospaced).bold())
.foregroundStyle(.primary)
Spacer()
statusBadge
}
// Input arguments
if !toolCall.arguments.isEmpty {
DisclosureGroup("Input") {
Text(prettyJSON(toolCall.arguments))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.black.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.font(.caption)
.foregroundStyle(.secondary)
}
// Output
if let output = toolCall.output {
DisclosureGroup("Output") {
Text(output.prettyString)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.black.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(.quaternary.opacity(0.3))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(borderColor, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
Spacer(minLength: 60)
}
.padding(.horizontal, 16)
}
@ViewBuilder
private var statusIcon: some View {
switch toolCall.status {
case "running":
ProgressView()
.controlSize(.small)
case "success":
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case "error":
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
default:
Image(systemName: "questionmark.circle")
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var statusBadge: some View {
Text(toolCall.status.capitalized)
.font(.system(size: 10, design: .monospaced))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(badgeColor.opacity(0.15))
.foregroundStyle(badgeColor)
.clipShape(Capsule())
}
private var badgeColor: Color {
switch toolCall.status {
case "running": return .orange
case "success": return .green
case "error": return .red
default: return .secondary
}
}
private var borderColor: Color {
switch toolCall.status {
case "running": return .orange.opacity(0.3)
case "success": return .green.opacity(0.2)
case "error": return .red.opacity(0.3)
default: return .clear
}
}
private func prettyJSON(_ jsonString: String) -> String {
guard let data = jsonString.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
let str = String(data: pretty, encoding: .utf8) else {
return jsonString
}
return str
}
}

View file

@ -0,0 +1,33 @@
{
"originHash" : "4ab20d6f9b3760314be6c8d471432a312fe8c0dbb4f3f28d71f6a6e9d6f6262d",
"pins" : [
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
}
],
"version" : 3
}

View file

@ -0,0 +1,19 @@
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "OmegaMac",
platforms: [.macOS(.v14)],
dependencies: [
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.0"),
],
targets: [
.executableTarget(
name: "OmegaMac",
dependencies: [
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
],
path: "OmegaMac"
),
]
)

115
apps/omega-mac/README.md Normal file
View file

@ -0,0 +1,115 @@
# Omega Mac
Native macOS chat app powered by the [TPMJS tool registry](https://tpmjs.com) — 1M+ AI-ready tools at your fingertips.
Omega Mac is the desktop counterpart to the web-based Omega agent. It connects directly to the OpenAI API and the TPMJS registry to search, discover, and execute tools in a secure remote sandbox — all from a native SwiftUI interface.
## Requirements
- macOS 15 (Sequoia) or later
- Xcode 16+
- An OpenAI API key
## Getting Started
1. Open `Package.swift` in Xcode
2. Wait for Swift Package Manager to resolve dependencies
3. Build and run (Cmd+R)
4. Open Settings (Cmd+,) and enter your OpenAI API key
5. Press Cmd+N to start a new conversation
## How It Works
Omega Mac implements a full **agentic tool-use loop**:
```
User message
→ Auto-discover relevant tools (BM25 search against tpmjs.com)
→ Build tool list (registrySearch + registryExecute + discovered tools)
→ Stream OpenAI response
→ If tool calls returned:
→ Execute tools via remote sandbox (executor.tpmjs.com)
→ Feed results back to OpenAI
→ Loop (up to 10 iterations)
→ Display final response
```
### Two Core Tools
Every conversation has access to two meta-tools that unlock the entire registry:
- **registrySearch** — Search 1M+ tools by keyword. Returns tool IDs and metadata.
- **registryExecute** — Execute any tool by its ID. Runs in a secure remote sandbox.
When you send a message, Omega also auto-discovers relevant tools via BM25 search and injects them as directly-callable functions — so the AI can call them without going through registryExecute.
## Architecture
```
OmegaMac/
├── Models/ SwiftData persistence
│ ├── Conversation Chat sessions with token tracking
│ ├── Message User/assistant/tool messages with JSON tool call data
│ ├── ToolCallRecord Individual tool execution records
│ ├── EnvVar Environment variable metadata (values in Keychain)
│ └── UserSettings Model selection, system prompt, pinned tools
├── Services/ Actor-based networking
│ ├── OpenAIService Streaming chat completions via SSE
│ ├── StreamParser Server-Sent Events line parser
│ ├── TPMJSRegistry Tool search + remote execution
│ ├── KeychainService Secure storage for API keys and env vars
│ └── ChatOrchestrator @Observable coordinator for the agentic loop
├── Views/ SwiftUI interface
│ ├── Sidebar/ Conversation list with @Query
│ ├── Chat/ Messages, input bar, streaming indicator
│ ├── Tools/ Tool call cards with collapsible JSON
│ ├── Settings/ API key, env vars, system prompt, model picker
│ └── Shared/ Markdown rendering, empty state
└── Utilities/ Tool name sanitization, system prompt builder
```
### Key Design Decisions
- **SwiftData** for local persistence — no server, no auth, everything on-device
- **Keychain** for secrets — API keys and env var values are encrypted at rest
- **Actors** for networking — `OpenAIService` and `TPMJSRegistryService` are actors for safe concurrent access
- **@Observable** — `ChatOrchestrator` drives all UI state with zero Combine boilerplate
- **Dark theme** by default — matches the web Omega aesthetic
## Settings
### API Key (required)
Your OpenAI API key is stored in the macOS Keychain. Omega Mac calls the OpenAI API directly — no proxy server.
### Model Selection
Choose from: `gpt-4.1-mini` (default), `gpt-4.1`, `gpt-4.1-nano`, `gpt-4o`, `gpt-4o-mini`, `o4-mini`.
### Environment Variables
Many tools in the TPMJS registry require API keys (e.g., `WEATHER_API_KEY`, `GITHUB_TOKEN`). Add them in Settings → Environment. Values are stored in Keychain; only key names and last-4-char hints are visible in the app.
All stored env vars are automatically passed to every tool execution.
### Custom System Prompt
Append custom instructions to Omega's default system prompt. Useful for constraining behavior, adding domain context, or specifying preferred tools.
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| Cmd+N | New conversation |
| Cmd+, | Open settings |
| Enter | Send message |
| Shift+Enter | New line in input |
## Dependencies
- [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui) — GitHub-flavored markdown rendering
- Everything else uses Apple frameworks (SwiftUI, SwiftData, Security, Foundation)
## Relationship to Web Omega
This app ports the core logic from the web implementation at `apps/web/src/app/api/omega/`. The agentic loop, system prompt, tool name sanitization, and search/execute flow are all faithful Swift translations of the TypeScript originals. The key difference is that web Omega uses server-side auth and a database, while Omega Mac stores everything locally with SwiftData and Keychain.

View file

@ -1,9 +1,9 @@
# Use official Deno image
FROM denoland/deno:1.39.0
# Use latest Deno LTS image for stability
FROM denoland/deno:2.1.9
# Install OpenSSH client for tools that need SSH access (e.g., exe-dev)
USER root
RUN apt-get update && apt-get install -y openssh-client && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client curl && rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
@ -19,5 +19,9 @@ RUN chmod +x start.sh
# Expose port (Railway will set PORT env var)
EXPOSE 3002
# Docker-level health check as a fallback
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:${PORT:-3002}/health || exit 1
# Run startup script that fixes permissions then starts Deno as deno user
CMD ["./start.sh"]

View file

@ -0,0 +1,8 @@
[build]
builder = "DOCKERFILE"
dockerfilePath = "Dockerfile"
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 30
restartPolicyType = "ALWAYS"

View file

@ -6,9 +6,25 @@
// Import zod-to-json-schema for Zod v3 support
import { zodToJsonSchema } from 'https://esm.sh/zod-to-json-schema@3.25.0';
// ─── Crash Protection ───────────────────────────────────────────────────────
// Catch unhandled promise rejections so they don't crash the process
globalThis.addEventListener('unhandledrejection', (event) => {
event.preventDefault();
console.error('⚠️ Unhandled promise rejection (caught, process continues):', event.reason);
});
// Catch uncaught errors
globalThis.addEventListener('error', (event) => {
console.error('⚠️ Uncaught error (caught, process continues):', event.error || event.message);
event.preventDefault();
});
// Cache TTL: 2 minutes
const CACHE_TTL_MS = 2 * 60 * 1000;
// Max cache entries to prevent unbounded memory growth
const MAX_CACHE_SIZE = 200;
// Cache entry with expiration
interface CacheEntry {
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic and vary by package
@ -41,6 +57,16 @@ function getCachedModule(cacheKey: string): CacheEntry | null {
*/
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic and vary by package
function setCachedModule(cacheKey: string, module: any, isFactory: boolean): void {
// Evict oldest entries if cache is full
if (moduleCache.size >= MAX_CACHE_SIZE) {
const entriesToEvict = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.2)); // Evict 20%
const keys = Array.from(moduleCache.keys());
for (let i = 0; i < entriesToEvict && i < keys.length; i++) {
moduleCache.delete(keys[i]);
}
console.log(`🗑️ Evicted ${entriesToEvict} cache entries (cache was full at ${MAX_CACHE_SIZE})`);
}
moduleCache.set(cacheKey, {
module,
expiresAt: Date.now() + CACHE_TTL_MS,
@ -845,6 +871,9 @@ async function listExports(req: Request): Promise<Response> {
}
}
// Track startup time for uptime reporting
const startedAt = Date.now();
/**
* Health check
*/
@ -852,7 +881,9 @@ function health(): Response {
return Response.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
cacheSize: moduleCache.size,
maxCacheSize: MAX_CACHE_SIZE,
denoVersion: Deno.version.deno,
v8Version: Deno.version.v8,
httpImports: true,
@ -893,9 +924,15 @@ function clearCache(): Response {
}
/**
* Main request handler
* Main request handler wrapped with crash protection so no single request
* can take down the process.
*/
async function handler(req: Request): Promise<Response> {
// Reject requests during shutdown
if (isShuttingDown) {
return new Response('Service shutting down', { status: 503 });
}
const url = new URL(req.url);
// Add CORS headers
@ -946,6 +983,24 @@ async function handler(req: Request): Promise<Response> {
}
}
// ─── Graceful Shutdown ──────────────────────────────────────────────────────
let isShuttingDown = false;
function handleShutdown(signal: string) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`\n🛑 Received ${signal}, shutting down gracefully...`);
moduleCache.clear();
// Give in-flight requests a moment to complete
setTimeout(() => {
console.log('👋 Goodbye');
Deno.exit(0);
}, 5000);
}
Deno.addSignalListener('SIGTERM', () => handleShutdown('SIGTERM'));
Deno.addSignalListener('SIGINT', () => handleShutdown('SIGINT'));
// Start server
const port = Number.parseInt(Deno.env.get('PORT') || '3002', 10);

View file

@ -4,4 +4,5 @@ mkdir -p /tmp/deno-cache
chown -R deno:deno /tmp/deno-cache
# Switch to deno user and run the server
# Deno 2.x: --allow-net, --allow-env, --allow-read, --allow-write, --allow-run for tool execution
exec su deno -c "deno run --allow-net --allow-env --allow-read --allow-write --allow-run server.ts"

View file

@ -12,9 +12,7 @@ const nextConfig: NextConfig = {
'@tpmjs/registry-execute',
],
reactStrictMode: true,
serverExternalPackages: [
'@tpmjs/package-executor',
],
serverExternalPackages: ['@tpmjs/package-executor'],
async redirects() {
return [
{

View file

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TPMJS Architecture</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
.loading {
display: flex; align-items: center; justify-content: center;
height: 100%; font-family: system-ui, sans-serif; color: #666;
}
.error { color: #666; text-align: center; }
.error a { color: #3b82f6; }
</style>
</head>
<body>
<div id="root"><div class="loading">Loading diagram...</div></div>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react-dom": "https://esm.sh/react-dom@18.2.0",
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
"isoflow": "https://esm.sh/isoflow@1.1.1?external=react,react-dom",
"@isoflow/isopacks/dist/isoflow": "https://esm.sh/@isoflow/isopacks@0.0.10/dist/isoflow.js"
}
}
</script>
<script type="module">
import React from 'react';
import ReactDOM from 'react-dom';
import isoflowPack from '@isoflow/isopacks/dist/isoflow';
const collection = isoflowPack.default || isoflowPack;
const icons = (collection.icons || []).map(icon => ({
id: icon.id, name: icon.name, url: icon.url,
collection: collection.name || 'Isoflow', isIsometric: icon.isIsometric
}));
const initialData = {
title: 'TPMJS Ecosystem',
icons,
colors: [
{ id: 'orange', value: '#f97316' },
{ id: 'blue', value: '#3b82f6' },
{ id: 'green', value: '#22c55e' },
{ id: 'purple', value: '#a855f7' },
{ id: 'pink', value: '#ec4899' }
],
items: [
// External Services
{ id: 'npm', name: 'npm Registry', icon: 'cloud' },
{ id: 'vercel', name: 'Vercel', icon: 'server' },
{ id: 'postgres', name: 'PostgreSQL', icon: 'storage' },
{ id: 'ai', name: 'AI Providers', icon: 'diamond' },
// Applications
{ id: 'web', name: 'tpmjs.com', icon: 'desktop' },
{ id: 'playground', name: 'Playground', icon: 'laptop' },
{ id: 'executor', name: 'Executor', icon: 'vm' },
// Published Packages
{ id: 'cli', name: '@tpmjs/cli', icon: 'function-module' },
{ id: 'types', name: '@tpmjs/types', icon: 'cube' },
{ id: 'ui', name: '@tpmjs/ui', icon: 'block' },
{ id: 'mcp', name: '@tpmjs/mcp-client', icon: 'loadbalancer' },
// Internal Packages
{ id: 'db', name: '@tpmjs/db', icon: 'storage' },
{ id: 'npm-client', name: '@tpmjs/npm-client', icon: 'package-module' },
// Official Tools
{ id: 'tools', name: '191+ AI Tools', icon: 'tower' }
],
views: [{
id: 'overview',
name: 'Ecosystem Overview',
items: [
// Row 0: External Services (2-tile spacing, compact)
{ id: 'npm', tile: { x: 0, y: 0 } },
{ id: 'vercel', tile: { x: 2, y: 0 } },
{ id: 'postgres', tile: { x: 4, y: 0 } },
{ id: 'ai', tile: { x: 6, y: 0 } },
// Row 2: Applications
{ id: 'web', tile: { x: 1, y: 2 } },
{ id: 'playground', tile: { x: 3, y: 2 } },
{ id: 'executor', tile: { x: 5, y: 2 } },
// Row 4: Published Packages
{ id: 'cli', tile: { x: 0, y: 4 } },
{ id: 'types', tile: { x: 2, y: 4 } },
{ id: 'ui', tile: { x: 4, y: 4 } },
{ id: 'mcp', tile: { x: 6, y: 4 } },
// Row 6: Internal + Tools
{ id: 'db', tile: { x: 1, y: 6 } },
{ id: 'npm-client', tile: { x: 3, y: 6 } },
{ id: 'tools', tile: { x: 5, y: 6 } }
],
connectors: [
// External → Apps
{ id: 'c1', color: 'orange', style: 'SOLID', anchors: [{ id: 'a1', ref: { item: 'vercel' } }, { id: 'a2', ref: { item: 'web' } }] },
{ id: 'c2', color: 'orange', style: 'DASHED', anchors: [{ id: 'a3', ref: { item: 'vercel' } }, { id: 'a4', ref: { item: 'playground' } }] },
{ id: 'c3', color: 'orange', style: 'SOLID', anchors: [{ id: 'a5', ref: { item: 'ai' } }, { id: 'a6', ref: { item: 'web' } }] },
{ id: 'c4', color: 'orange', style: 'DASHED', anchors: [{ id: 'a7', ref: { item: 'ai' } }, { id: 'a8', ref: { item: 'executor' } }] },
{ id: 'c5', color: 'purple', style: 'SOLID', anchors: [{ id: 'a9', ref: { item: 'postgres' } }, { id: 'a10', ref: { item: 'db' } }] },
// Apps → Packages
{ id: 'c6', color: 'blue', style: 'SOLID', anchors: [{ id: 'a11', ref: { item: 'web' } }, { id: 'a12', ref: { item: 'types' } }] },
{ id: 'c7', color: 'blue', style: 'SOLID', anchors: [{ id: 'a13', ref: { item: 'web' } }, { id: 'a14', ref: { item: 'ui' } }] },
{ id: 'c8', color: 'blue', style: 'DASHED', anchors: [{ id: 'a15', ref: { item: 'web' } }, { id: 'a16', ref: { item: 'mcp' } }] },
// Internal → Apps
{ id: 'c9', color: 'purple', style: 'SOLID', anchors: [{ id: 'a17', ref: { item: 'db' } }, { id: 'a18', ref: { item: 'web' } }] },
{ id: 'c10', color: 'purple', style: 'DASHED', anchors: [{ id: 'a19', ref: { item: 'npm-client' } }, { id: 'a20', ref: { item: 'web' } }] },
{ id: 'c11', color: 'orange', style: 'DASHED', anchors: [{ id: 'a21', ref: { item: 'npm' } }, { id: 'a22', ref: { item: 'npm-client' } }] },
// Tools pipeline
{ id: 'c12', color: 'pink', style: 'SOLID', anchors: [{ id: 'a23', ref: { item: 'tools' } }, { id: 'a24', ref: { item: 'npm' } }] },
{ id: 'c13', color: 'pink', style: 'DASHED', anchors: [{ id: 'a25', ref: { item: 'tools' } }, { id: 'a26', ref: { item: 'cli' } }] }
]
}],
fitToView: true,
view: 'overview'
};
try {
const { default: Isoflow } = await import('isoflow');
ReactDOM.render(
React.createElement(Isoflow, {
initialData,
editorMode: 'EXPLORABLE_READONLY',
width: '100%',
height: '100%',
renderer: { showGrid: true }
}),
document.getElementById('root')
);
// After fitToView renders, zoom in for better label readability
setTimeout(() => {
const zoomIn = document.querySelector('[aria-label="Zoom in"]') ||
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === '+');
if (zoomIn) {
for (let i = 0; i < 4; i++) setTimeout(() => zoomIn.click(), i * 100);
}
}, 1500);
} catch (err) {
console.error('Isoflow load error:', err);
document.getElementById('root').innerHTML =
'<div class="loading error"><p>Failed to load diagram. <a href="/architecture">View alternative</a></p></div>';
}
</script>
</body>
</html>

85
apps/web/public/llms.txt Normal file
View file

@ -0,0 +1,85 @@
# TPMJS — The NPM for AI Tools
> TPMJS is an open tool registry and platform for discovering, publishing, and executing AI agent tools from npm. Any npm package with the `tpmjs` keyword is automatically discovered and made available. Tools use the Vercel AI SDK `tool()` pattern and work with Claude, GPT, LangChain, and any MCP-compatible client. The platform provides collections for organizing tools, custom agents with multi-provider support, scenario testing, living skills documentation, and a unified MCP endpoint.
## Getting Started
- [Homepage](https://tpmjs.com): Browse the registry and discover AI tools
- [Quickstart Guide](https://tpmjs.com/docs/quickstart): Get started with TPMJS in minutes
- [How It Works](https://tpmjs.com/how-it-works): Overview of the platform architecture and tool lifecycle
- [Platform Guide](https://tpmjs.com/docs/platform-guide): Comprehensive guide to all platform features
- [Publishing Tools](https://tpmjs.com/publish): How to create and publish your own AI tools to the registry
- [FAQ](https://tpmjs.com/faq): Frequently asked questions
## Documentation
- [Docs Home](https://tpmjs.com/docs): Documentation index
- [Architecture](https://tpmjs.com/docs/architecture): System design, monorepo structure, and technical decisions
- [SDK Reference](https://tpmjs.com/docs/sdk): JavaScript/TypeScript SDK for building with TPMJS tools
- [Tool Specification](https://tpmjs.com/spec): The TPMJS package.json `tpmjs` field specification for tool authors
- [Agents Guide](https://tpmjs.com/docs/agents): Creating and managing AI agents with multi-provider support
- [Scenarios Guide](https://tpmjs.com/docs/scenarios): AI-generated test scenarios for validating tool integrations
- [Skills Guide](https://tpmjs.com/docs/skills): Living documentation that evolves from agent usage patterns
- [Sharing Guide](https://tpmjs.com/docs/sharing): How to share collections, agents, and tools publicly
- [Developer Guide](https://tpmjs.com/docs/developers/guide): In-depth guide for tool developers
## Tutorials
- [MCP Integration](https://tpmjs.com/docs/tutorials/mcp): Connect TPMJS tools to Claude Desktop, Cursor, and other MCP clients
- [Bridge Setup](https://tpmjs.com/docs/tutorials/bridge): Bridge local MCP servers to the TPMJS cloud
- [Custom Agents](https://tpmjs.com/docs/tutorials/agents): Build AI agents with curated tool collections
- [Custom Executors](https://tpmjs.com/docs/tutorials/custom-executor): Run tools on your own infrastructure
## API Reference
- [API Overview](https://tpmjs.com/docs/api): REST API introduction and authentication
- [Authentication](https://tpmjs.com/docs/api/authentication): API key management and auth patterns
- [Tools API](https://tpmjs.com/docs/api/tools): Search, list, execute, and validate tools
- [Collections API](https://tpmjs.com/docs/api/collections): Create and manage tool collections
- [Agents API](https://tpmjs.com/docs/api/agents): Create, configure, and chat with AI agents
- [Scenarios API](https://tpmjs.com/docs/api/scenarios): Generate, run, and manage test scenarios
## Executors
- [Executors Overview](https://tpmjs.com/docs/executors): How tool execution works in TPMJS
- [Unsandbox Executor](https://tpmjs.com/docs/executors/unsandbox): Sandboxed code execution environment
- [Vercel Executor](https://tpmjs.com/docs/executors/vercel): Deploy executors on Vercel
- [Railway Executor](https://tpmjs.com/docs/executors/railway): Deploy executors on Railway
## Explore
- [Tool Search](https://tpmjs.com/tool/tool-search): Search the full tool registry with filters
- [Collections](https://tpmjs.com/collections): Browse public tool collections
- [Agents](https://tpmjs.com/agents): Browse public AI agents
- [Scenarios](https://tpmjs.com/scenarios): Browse featured test scenarios
- [Use Cases](https://tpmjs.com/use-cases): Real-world use cases generated from scenario testing
- [Playground](https://tpmjs.com/playground): Interactive tool testing playground
- [Registry Stats](https://tpmjs.com/stats): Live registry statistics and health dashboard
- [Tool Ideas](https://tpmjs.com/tool-ideas): Community-sourced AI tool ideas
## CLI
- [CLI Auth](https://tpmjs.com/cli/auth): Authenticate the TPMJS CLI
## npm Packages
- [@tpmjs/cli](https://www.npmjs.com/package/@tpmjs/cli): Command-line interface with 37 commands for tools, agents, collections, scenarios, and MCP
- [@tpmjs/types](https://www.npmjs.com/package/@tpmjs/types): Shared TypeScript types and Zod schemas
- [@tpmjs/ui](https://www.npmjs.com/package/@tpmjs/ui): React component library for TPMJS applications
- [@tpmjs/utils](https://www.npmjs.com/package/@tpmjs/utils): Utility functions including className merging and formatters
- [@tpmjs/env](https://www.npmjs.com/package/@tpmjs/env): Environment variable validation with Zod
- [@tpmjs/mcp-client](https://www.npmjs.com/package/@tpmjs/mcp-client): MCP client library for connecting to Model Context Protocol servers
- [@tpmjs/bridge](https://www.npmjs.com/package/@tpmjs/bridge): Bridge CLI for connecting local MCP servers to TPMJS cloud
- [@tpmjs/executor-test](https://www.npmjs.com/package/@tpmjs/executor-test): Executor protocol compliance test suite
## Optional
- [Features Overview](https://tpmjs.com/features): Marketing overview of all platform capabilities
- [Integrations](https://tpmjs.com/integrations): Supported AI platforms and MCP clients
- [Changelog](https://tpmjs.com/changelog): Release history and updates
- [About](https://tpmjs.com/about): About the TPMJS project
- [Privacy Policy](https://tpmjs.com/privacy): Privacy policy
- [Terms of Service](https://tpmjs.com/terms): Terms of service
- [Broken Tools](https://tpmjs.com/tool/broken): Tools with health issues
- [Omega Agent](https://tpmjs.com/omega): Chat-based tool discovery and execution agent
- [Style Guide](https://tpmjs.com/style-guide): Visual design system reference

View file

@ -11,6 +11,7 @@ import { AppHeader } from '~/components/AppHeader';
import { ForkButton } from '~/components/ForkButton';
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
import { LikeButton } from '~/components/LikeButton';
import { useTrackView } from '~/hooks/useTrackView';
import { useSession } from '~/lib/auth-client';
interface AgentTool {
@ -183,6 +184,7 @@ const response = await fetch(\`${apiUrl}/\${conversation.id}\`, {
);
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large detail page with many conditional sections
export default function PrettyAgentDetailPage(): React.ReactElement {
const params = useParams();
const rawUsername = params.username as string;
@ -194,6 +196,9 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Track page view
useTrackView('agent', agent?.id ?? '');
// Check if current user is the owner
const isOwner = session?.user?.id && agent?.createdBy?.id === session.user.id;

View file

@ -5,7 +5,7 @@ import { Button } from '@tpmjs/ui/Button/Button';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
import { ForkButton } from '~/components/ForkButton';
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
@ -13,8 +13,37 @@ import { LikeButton } from '~/components/LikeButton';
import { ScenariosSection } from '~/components/ScenariosSection';
import { ShareButton } from '~/components/ShareButton';
import { SkillsSection } from '~/components/skills/SkillsSection';
import { UseCasesSection } from '~/components/UseCasesSection';
import { useTrackView } from '~/hooks/useTrackView';
import { useSession } from '~/lib/auth-client';
/**
* Locked state for private collections viewed by non-owners
* Shows minimal information: just name and "Private" badge
*/
export function PrivateCollectionLocked({ name }: { name: string }) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-5xl mx-auto px-4 py-8">
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-16 h-16 rounded-full bg-foreground-tertiary/10 flex items-center justify-center mb-6">
<Icon icon="key" className="w-8 h-8 text-foreground-tertiary" />
</div>
<div className="flex items-center gap-2 mb-4">
<h1 className="text-2xl font-bold text-foreground">{name}</h1>
<Badge variant="secondary">Private</Badge>
</div>
<p className="text-foreground-secondary max-w-md">
This collection is private and can only be viewed by its owner.
</p>
</div>
</main>
</div>
);
}
export interface CollectionTool {
id: string;
toolId: string;
@ -32,6 +61,20 @@ export interface CollectionTool {
};
}
export interface UseCaseToolStep {
toolName: string;
packageName: string;
purpose: string;
order: number;
}
export interface UseCase {
id: string;
userPrompt: string;
description: string;
toolSequence: UseCaseToolStep[];
}
export interface PublicCollection {
id: string;
slug: string; // Already coerced to empty string if null in server component
@ -57,6 +100,8 @@ export interface PublicCollection {
username: string;
};
} | null;
useCases?: UseCase[] | null;
useCasesGeneratedAt?: string | null;
}
function McpUrlSection({
@ -82,14 +127,15 @@ function McpUrlSection({
setTimeout(() => setCopiedUrl(null), 2000);
};
// Claude Code CLI command (correct arg order: options before name and url)
const claudeCodeCommand = `claude mcp add tpmjs-${slug} ${httpUrl} -t http`;
// Claude Desktop native HTTP config
const configSnippet = `{
"mcpServers": {
"tpmjs-${slug}": {
"command": "npx",
"args": [
"mcp-remote",
"${httpUrl}"
]
"type": "http",
"url": "${httpUrl}"
}
}
}`;
@ -188,6 +234,27 @@ const response = await fetch("${httpUrl}", {
</div>
)}
{/* Claude Code CLI command */}
<div className="mt-4 pt-4 border-t border-border/50">
<h4 className="text-sm font-medium text-foreground mb-2">Add to Claude Code</h4>
<div className="relative">
<pre className="p-3 bg-surface border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all">
{claudeCodeCommand}
</pre>
<Button
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(claudeCodeCommand)}
className="absolute top-1.5 right-1.5"
>
<Icon icon="copy" className="w-3.5 h-3.5" />
</Button>
</div>
<p className="mt-1.5 text-xs text-foreground-tertiary">
Run <code className="font-mono">/mcp</code> in Claude Code to verify the connection.
</p>
</div>
{/* Config snippet toggle */}
<div className="mt-4 pt-4 border-t border-border/50 space-y-2">
<button
@ -240,12 +307,31 @@ interface CollectionDetailClientProps {
username: string;
}
export function CollectionDetailClient({ collection, username }: CollectionDetailClientProps) {
export function CollectionDetailClient({
collection: initialCollection,
username,
}: CollectionDetailClientProps) {
const { data: session } = useSession();
const [collection, setCollection] = useState(initialCollection);
// Track page view
useTrackView('collection', collection.id);
// Check if current user is the owner
const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id;
// Handler for when use cases are generated
const handleUseCasesGenerated = useCallback(
(useCases: UseCase[], generatedAt: string) => {
setCollection({
...collection,
useCases,
useCasesGeneratedAt: generatedAt,
});
},
[collection]
);
// Generate tweet text
const tweetText = collection.description
? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}`
@ -361,6 +447,16 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
</div>
)}
{/* Use Cases Section */}
{collection.tools.length > 0 && (
<UseCasesSection
collectionId={collection.id}
useCases={collection.useCases ?? null}
generatedAt={collection.useCasesGeneratedAt ?? null}
onUseCasesGenerated={handleUseCasesGenerated}
/>
)}
{/* Scenarios Section */}
{collection.tools.length > 0 && (
<ScenariosSection

View file

@ -0,0 +1,359 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { API_KEY_SCOPES } from '~/lib/api-keys';
import { authenticateRequest, getClientMetadata, hasScope } from '~/lib/api-keys/middleware';
import {
checkApiKeyRateLimit,
createRateLimitResponse,
getRateLimitHeaders,
} from '~/lib/api-keys/rate-limit';
import { trackUsage } from '~/lib/api-keys/usage';
import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries
interface RouteContext {
params: Promise<{ username: string; slug: string }>;
}
interface JsonRpcRequest {
jsonrpc: string;
method: string;
params?: unknown;
id?: string | number;
}
interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number | null;
result?: unknown;
error?: { code: number; message: string };
}
/**
* Wrap a promise with a timeout
*/
function withTimeout<T>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)),
]);
}
/**
* Find a user by username (strips @ prefix if present)
*/
async function getUserByUsername(username: string) {
// Strip @ prefix if present (from pretty URLs like /@username)
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
return withTimeout(
prisma.user.findUnique({
where: { username: cleanUsername },
select: { id: true, username: true },
}),
DB_TIMEOUT_MS,
`Database query timed out after ${DB_TIMEOUT_MS}ms`
);
}
/**
* Find a collection by user ID and slug
*/
async function getCollectionByUserIdAndSlug(userId: string, slug: string) {
return withTimeout(
prisma.collection.findFirst({
where: {
userId,
slug,
},
select: { id: true, name: true, description: true, userId: true, isPublic: true },
}),
DB_TIMEOUT_MS,
`Database query timed out after ${DB_TIMEOUT_MS}ms`
);
}
/**
* Process a JSON-RPC request and return the response
*/
async function processJsonRpcRequest(
collectionId: string,
collectionName: string,
body: JsonRpcRequest,
isOwner: boolean
): Promise<JsonRpcResponse> {
const requestId = body.id ?? null;
switch (body.method) {
case 'initialize':
return handleInitialize(collectionName, requestId);
case 'tools/list':
return await handleToolsList(collectionId, requestId);
case 'tools/call': {
const params = body.params as {
name: string;
arguments?: Record<string, unknown>;
env?: Record<string, string>;
};
// For non-owners, use caller-provided env vars (or empty if not provided)
// For owners, callerEnvVars is undefined so handleToolsCall uses stored env vars
const callerEnvVars = isOwner ? undefined : params.env || {};
return await handleToolsCall(collectionId, params, requestId, callerEnvVars);
}
case 'notifications/initialized':
case 'ping':
return { jsonrpc: '2.0', id: requestId, result: {} };
default:
return {
jsonrpc: '2.0',
id: requestId,
error: { code: -32601, message: `Method not found: ${body.method}` },
};
}
}
/**
* POST /@username/collections/[slug]/mcp
* MCP JSON-RPC endpoint (HTTP transport only)
*
* Authentication:
* - Public collections: No auth required
* - Private collections: Requires Authorization: Bearer header with valid API key
*/
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
const startTime = Date.now();
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | null = null;
try {
const { username, slug } = await context.params;
// First, find the user by username
const user = await getUserByUsername(username);
if (!user) {
return NextResponse.json(
{
jsonrpc: '2.0',
error: {
code: -32001,
message: `User '${username}' not found. Check the username in your MCP endpoint URL.`,
},
id: null,
},
{ status: 404 }
);
}
// Then find the collection by user ID and slug
const collection = await getCollectionByUserIdAndSlug(user.id, slug);
if (!collection) {
return NextResponse.json(
{
jsonrpc: '2.0',
error: {
code: -32001,
message: `Collection '${slug}' not found for user '${user.username}'.`,
},
id: null,
},
{ status: 404 }
);
}
// Authenticate the request
authResult = await authenticateRequest();
// Determine if the authenticated user is the owner
const isOwner = authResult.authenticated && authResult.userId === collection.userId;
// Authorization check:
// - Owners can always access their own collections (public or private)
// - Non-owners can access PUBLIC collections without auth
// - Private collections require auth as the owner
if (!isOwner && !collection.isPublic) {
// Private collection, not the owner - require authentication
if (!authResult.authenticated) {
return NextResponse.json(
{
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Authentication required. Add header: Authorization: Bearer YOUR_API_KEY',
},
id: null,
},
{ status: 401 }
);
}
// Authenticated but not the owner of a private collection - don't reveal existence
return NextResponse.json(
{ jsonrpc: '2.0', error: { code: -32001, message: 'Collection not found' }, id: null },
{ status: 404 }
);
}
// Check scope if authenticated
if (authResult.authenticated && !hasScope(authResult, API_KEY_SCOPES.MCP_EXECUTE)) {
return NextResponse.json(
{
jsonrpc: '2.0',
error: { code: -32000, message: 'Missing required scope: mcp:execute' },
id: null,
},
{ status: 403 }
);
}
// Rate limit if authenticated via API key
if (authResult.authenticated && authResult.apiKeyId) {
const rateLimitResult = await checkApiKeyRateLimit(
authResult.apiKeyId,
authResult.tier || 'FREE'
);
if (!rateLimitResult.allowed) {
return createRateLimitResponse(rateLimitResult);
}
}
// Parse JSON-RPC request body
let body: JsonRpcRequest;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null },
{ status: 400 }
);
}
// Process the request
const response = await processJsonRpcRequest(collection.id, collection.name, body, isOwner);
const jsonResponse = NextResponse.json(response);
// Track usage for authenticated requests
if (authResult.authenticated && authResult.userId) {
const clientMeta = await getClientMetadata();
trackUsage({
apiKeyId: authResult.apiKeyId,
userId: authResult.userId,
endpoint: `/@${user.username}/collections/${slug}/mcp`,
method: 'POST',
statusCode: jsonResponse.status,
latencyMs: Date.now() - startTime,
resourceType: 'mcp',
resourceId: collection.id,
userAgent: clientMeta.userAgent,
ipAddress: clientMeta.ipAddress,
});
}
// Add rate limit headers for authenticated requests
if (authResult.authenticated && authResult.apiKeyId) {
const rateLimitResult = await checkApiKeyRateLimit(
authResult.apiKeyId,
authResult.tier || 'FREE'
);
const headers = getRateLimitHeaders(rateLimitResult);
for (const [key, value] of Object.entries(headers)) {
jsonResponse.headers.set(key, value);
}
}
return jsonResponse;
} catch (error) {
console.error('[MCP POST] Error:', error);
const message = error instanceof Error ? error.message : 'Internal server error';
// Track error for authenticated requests
if (authResult?.authenticated && authResult.userId) {
const { username, slug } = await context.params;
const clientMeta = await getClientMetadata();
trackUsage({
apiKeyId: authResult.apiKeyId,
userId: authResult.userId,
endpoint: `/@${username}/collections/${slug}/mcp`,
method: 'POST',
statusCode: 500,
latencyMs: Date.now() - startTime,
resourceType: 'mcp',
errorCode: 'INTERNAL_ERROR',
errorMessage: message,
userAgent: clientMeta.userAgent,
ipAddress: clientMeta.ipAddress,
});
}
return NextResponse.json(
{ jsonrpc: '2.0', error: { code: -32603, message }, id: null },
{ status: 500 }
);
}
}
/**
* GET /@username/collections/[slug]/mcp
* Returns server info for the MCP endpoint
*/
export async function GET(_request: NextRequest, context: RouteContext): Promise<Response> {
try {
const { username, slug } = await context.params;
// First, find the user by username
const user = await getUserByUsername(username);
if (!user) {
return NextResponse.json(
{ error: `User '${username}' not found. Check the username in your MCP endpoint URL.` },
{ status: 404 }
);
}
// Then find the collection
const collection = await getCollectionByUserIdAndSlug(user.id, slug);
if (!collection) {
return NextResponse.json(
{ error: `Collection '${slug}' not found for user '${user.username}'.` },
{ status: 404 }
);
}
// For GET requests, check if user can access this collection:
// - Public collections are accessible to anyone
// - Private collections are only accessible to the owner (when authenticated)
if (!collection.isPublic) {
const authResult = await authenticateRequest();
if (!authResult.authenticated || authResult.userId !== collection.userId) {
// Don't reveal existence of private collections
return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
}
}
// Return server info
return NextResponse.json({
name: `TPMJS: ${collection.name}`,
description: collection.description,
protocol: 'mcp',
transport: 'http',
endpoint: `/@${user.username}/collections/${slug}/mcp`,
});
} catch (error) {
console.error('[MCP GET] Error:', error);
const message = error instanceof Error ? error.message : 'Internal server error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View file

@ -1,7 +1,11 @@
import { prisma } from '@tpmjs/db';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CollectionDetailClient, type PublicCollection } from './CollectionDetailClient';
import {
CollectionDetailClient,
PrivateCollectionLocked,
type PublicCollection,
} from './CollectionDetailClient';
export const dynamic = 'force-dynamic';
@ -9,18 +13,25 @@ interface CollectionPageProps {
params: Promise<{ username: string; slug: string }>;
}
interface CollectionResult {
collection: PublicCollection | null;
isPrivate: boolean;
privateName?: string;
}
/**
* Fetch collection data from database
* Returns both public collections fully, and private collections with minimal info (locked state)
*/
async function getCollection(username: string, slug: string): Promise<PublicCollection | null> {
async function getCollection(username: string, slug: string): Promise<CollectionResult> {
// Remove @ prefix if present
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
// First, check if the collection exists at all (public or private)
const collection = await prisma.collection.findFirst({
where: {
slug,
user: { username: cleanUsername },
isPublic: true,
},
include: {
user: {
@ -57,51 +68,64 @@ async function getCollection(username: string, slug: string): Promise<PublicColl
});
if (!collection) {
return null;
return { collection: null, isPrivate: false };
}
// If private, return minimal info for locked state
if (!collection.isPublic) {
return {
collection: null,
isPrivate: true,
privateName: collection.name,
};
}
// Public collection - return full data
return {
id: collection.id,
slug: collection.slug || '',
name: collection.name,
description: collection.description,
likeCount: collection.likeCount,
toolCount: collection.tools.length,
forkCount: collection.forkCount,
createdAt: collection.createdAt.toISOString(),
createdBy: {
id: collection.user.id,
username: collection.user.username || '',
name: collection.user.name || '',
image: collection.user.image,
},
tools: collection.tools.map((ct) => ({
id: ct.id,
toolId: ct.toolId,
position: ct.position,
note: ct.note,
tool: {
id: ct.tool.id,
name: ct.tool.name,
description: ct.tool.description,
likeCount: ct.tool.likeCount,
package: {
npmPackageName: ct.tool.package.npmPackageName,
category: ct.tool.package.category,
},
collection: {
id: collection.id,
slug: collection.slug || '',
name: collection.name,
description: collection.description,
likeCount: collection.likeCount,
toolCount: collection.tools.length,
forkCount: collection.forkCount,
createdAt: collection.createdAt.toISOString(),
createdBy: {
id: collection.user.id,
username: collection.user.username || '',
name: collection.user.name || '',
image: collection.user.image,
},
})),
forkedFromId: collection.forkedFromId,
forkedFrom: collection.forkedFrom
? {
id: collection.forkedFrom.id,
name: collection.forkedFrom.name,
slug: collection.forkedFrom.slug || '',
user: {
username: collection.forkedFrom.user.username || '',
tools: collection.tools.map((ct) => ({
id: ct.id,
toolId: ct.toolId,
position: ct.position,
note: ct.note,
tool: {
id: ct.tool.id,
name: ct.tool.name,
description: ct.tool.description,
likeCount: ct.tool.likeCount,
package: {
npmPackageName: ct.tool.package.npmPackageName,
category: ct.tool.package.category,
},
}
: null,
},
})),
forkedFromId: collection.forkedFromId,
forkedFrom: collection.forkedFrom
? {
id: collection.forkedFrom.id,
name: collection.forkedFrom.name,
slug: collection.forkedFrom.slug || '',
user: {
username: collection.forkedFrom.user.username || '',
},
}
: null,
},
isPrivate: false,
};
}
@ -111,15 +135,25 @@ async function getCollection(username: string, slug: string): Promise<PublicColl
export async function generateMetadata({ params }: CollectionPageProps): Promise<Metadata> {
const { username, slug } = await params;
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
const collection = await getCollection(username, slug);
const result = await getCollection(username, slug);
if (!collection) {
// Private collection - minimal metadata
if (result.isPrivate) {
return {
title: `${result.privateName} (Private) | TPMJS`,
description: 'This collection is private.',
robots: { index: false, follow: false },
};
}
if (!result.collection) {
return {
title: 'Collection Not Found | TPMJS',
description: 'The requested collection could not be found.',
};
}
const collection = result.collection;
const title = `${collection.name} | TPMJS`;
const description =
collection.description ||
@ -174,11 +208,17 @@ export async function generateMetadata({ params }: CollectionPageProps): Promise
export default async function CollectionDetailPage({ params }: CollectionPageProps) {
const { username, slug } = await params;
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
const collection = await getCollection(username, slug);
const result = await getCollection(username, slug);
if (!collection) {
// Private collection - show locked state
if (result.isPrivate && result.privateName) {
return <PrivateCollectionLocked name={result.privateName} />;
}
// Collection not found
if (!result.collection) {
notFound();
}
return <CollectionDetailClient collection={collection} username={cleanUsername} />;
return <CollectionDetailClient collection={result.collection} username={cleanUsername} />;
}

View file

@ -131,7 +131,11 @@ export function QuestionsListClient({
const clearSkillFilter = () => {
setSkillFilter(undefined);
window.history.replaceState(null, '', `/${collection.username}/collections/${collection.slug}/skills/questions`);
window.history.replaceState(
null,
'',
`/${collection.username}/collections/${collection.slug}/skills/questions`
);
};
const basePath = `/${collection.username}/collections/${collection.slug}`;
@ -224,7 +228,7 @@ export function QuestionsListClient({
description={
skillFilter
? `No questions found for skill "${skillFilter}"`
: 'Be the first to ask a question about this collection\'s tools.'
: "Be the first to ask a question about this collection's tools."
}
size="md"
/>
@ -243,11 +247,7 @@ export function QuestionsListClient({
{!loading && !error && questions.length > 0 && (
<div className="space-y-4">
{questions.map((q) => (
<Link
key={q.id}
href={`${basePath}/skills/questions/${q.id}`}
className="block"
>
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
<Card
variant="default"
className="hover:border-primary/30 hover:bg-muted/30 transition-all cursor-pointer"
@ -311,11 +311,7 @@ export function QuestionsListClient({
{/* Load More */}
{hasMore && (
<div className="text-center pt-4">
<Button
variant="secondary"
onClick={handleLoadMore}
disabled={loadingMore}
>
<Button variant="secondary" onClick={handleLoadMore} disabled={loadingMore}>
{loadingMore ? (
<>
<Icon icon="loader" className="w-4 h-4 mr-2 animate-spin" />

View file

@ -90,9 +90,10 @@ export function QuestionDetailClient({
const [copied, setCopied] = useState(false);
const basePath = `/${collection.username}/collections/${collection.slug}`;
const questionUrl = typeof window !== 'undefined'
? window.location.href
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
const questionUrl =
typeof window !== 'undefined'
? window.location.href
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
const copyLink = async () => {
await navigator.clipboard.writeText(questionUrl);
@ -140,10 +141,7 @@ export function QuestionDetailClient({
<Icon icon="clock" className="w-4 h-4" />
{formatDate(question.createdAt)}
</span>
<Badge
variant={question.confidence >= 0.7 ? 'success' : 'secondary'}
size="md"
>
<Badge variant={question.confidence >= 0.7 ? 'success' : 'secondary'} size="md">
{Math.round(question.confidence * 100)}% confidence
</Badge>
{question.similarCount > 0 && (
@ -233,7 +231,9 @@ export function QuestionDetailClient({
className="block p-2 bg-muted/50 border border-border rounded hover:border-primary/30 transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm text-foreground">{sn.skill.name}</span>
<span className="font-medium text-sm text-foreground">
{sn.skill.name}
</span>
<Badge variant="outline" size="sm">
{sn.skill.questionCount} Q
</Badge>
@ -288,10 +288,7 @@ export function QuestionDetailClient({
>
<p className="text-sm text-foreground line-clamp-2">{sq.question}</p>
<div className="flex items-center justify-between mt-1.5">
<Badge
variant={sq.confidence >= 0.7 ? 'success' : 'secondary'}
size="sm"
>
<Badge variant={sq.confidence >= 0.7 ? 'success' : 'secondary'} size="sm">
{Math.round(sq.confidence * 100)}%
</Badge>
<span className="text-xs text-foreground-tertiary">

View file

@ -110,9 +110,7 @@ export async function generateMetadata({ params }: QuestionPageProps): Promise<M
}
const truncatedQuestion =
question.question.length > 60
? question.question.slice(0, 60) + '...'
: question.question;
question.question.length > 60 ? `${question.question.slice(0, 60)}...` : question.question;
return {
title: `${truncatedQuestion} | TPMJS Skills`,

View file

@ -38,6 +38,7 @@ function truncateText(text: string, maxLength: number): string {
return `${text.slice(0, maxLength).trim()}...`;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large page component with table rendering
export default function PublicAgentsPage(): React.ReactElement {
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortOption>('likes');
@ -70,9 +71,10 @@ export default function PublicAgentsPage(): React.ReactElement {
() => (
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
<th className="px-4 py-3 w-[200px]">Name</th>
<th className="px-4 py-3 w-[250px]">Description</th>
<th className="px-4 py-3 w-[200px]">Description</th>
<th className="px-4 py-3 w-[100px]">Provider</th>
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
<th className="px-4 py-3 w-[70px] text-center">Forks</th>
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
<th className="px-4 py-3 w-[150px]">Creator</th>
<th className="px-4 py-3 w-[80px] text-center">Chat</th>
@ -106,6 +108,9 @@ export default function PublicAgentsPage(): React.ReactElement {
{agent.toolCount}
</Badge>
</td>
<td className="px-4 py-3 text-center text-sm text-foreground-secondary">
{agent.forkCount > 0 ? agent.forkCount : '—'}
</td>
<td className="px-4 py-3 text-center">
<LikeButton
entityType="agent"

View file

@ -0,0 +1,87 @@
import { prisma } from '@tpmjs/db';
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 10;
// Only show these activity types publicly (positive actions, not deletions/unlikes)
const PUBLIC_ACTIVITY_TYPES = [
'TOOL_LIKED',
'COLLECTION_CREATED',
'COLLECTION_FORKED',
'COLLECTION_TOOL_ADDED',
'AGENT_CREATED',
'AGENT_FORKED',
'AGENT_LIKED',
'COLLECTION_LIKED',
] as const;
/**
* GET /api/activity/public
* Returns recent public activity for the homepage activity stream.
* Cached for 30 seconds with stale-while-revalidate.
*/
export async function GET() {
try {
const activities = await prisma.userActivity.findMany({
where: {
type: { in: [...PUBLIC_ACTIVITY_TYPES] },
},
orderBy: { createdAt: 'desc' },
take: 20,
select: {
id: true,
type: true,
targetName: true,
targetType: true,
createdAt: true,
user: {
select: {
username: true,
name: true,
},
},
},
});
const data = activities.map((a) => ({
id: a.id,
type: mapActivityType(a.type),
username: a.user.username || a.user.name,
targetName: a.targetName,
targetType: a.targetType,
createdAt: a.createdAt,
}));
return NextResponse.json(
{ success: true, data },
{
headers: {
'Cache-Control': 's-maxage=30, stale-while-revalidate=60',
},
}
);
} catch (error) {
console.error('[activity/public] Error:', error);
return NextResponse.json({ success: true, data: [] });
}
}
function mapActivityType(type: string): 'invoked' | 'published' | 'updated' {
switch (type) {
case 'TOOL_LIKED':
case 'COLLECTION_LIKED':
case 'AGENT_LIKED':
return 'invoked';
case 'COLLECTION_CREATED':
case 'AGENT_CREATED':
return 'published';
case 'COLLECTION_FORKED':
case 'AGENT_FORKED':
case 'COLLECTION_TOOL_ADDED':
return 'updated';
default:
return 'updated';
}
}

View file

@ -1,42 +0,0 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/admin/make-agents-public
* One-off endpoint to update all existing agents to be public.
* Protected by CRON_SECRET.
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
// Verify authorization
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// Update all agents to be public
const result = await prisma.agent.updateMany({
where: { isPublic: false },
data: { isPublic: true },
});
// Get all agents for verification
const agents = await prisma.agent.findMany({
select: { id: true, name: true, isPublic: true },
});
return NextResponse.json({
success: true,
updated: result.count,
agents: agents.map((a) => ({ name: a.name, isPublic: a.isPublic })),
});
} catch (error) {
console.error('Failed to update agents:', error);
return NextResponse.json({ success: false, error: 'Failed to update agents' }, { status: 500 });
}
}

View file

@ -203,6 +203,49 @@ async function searchRelevantTools(
}));
}
/**
* Fetch the tool's inputSchema from the executor's loadAndDescribe endpoint.
* This is used when the schema isn't available in the database yet.
*/
async function fetchSchemaFromExecutor(toolMeta: {
packageName: string;
name: string;
version: string;
importUrl: string;
}): Promise<unknown | null> {
try {
console.log(`📋 Fetching schema from executor for ${toolMeta.packageName}/${toolMeta.name}`);
const response = await fetch(`${EXECUTOR_URL}/load-and-describe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
packageName: toolMeta.packageName,
name: toolMeta.name,
version: toolMeta.version,
importUrl: toolMeta.importUrl,
}),
});
if (!response.ok) {
console.warn(
`⚠️ Schema fetch failed (${response.status}) for ${toolMeta.packageName}/${toolMeta.name}`
);
return null;
}
// biome-ignore lint/suspicious/noExplicitAny: Executor response format varies
const result = (await response.json()) as any;
if (result.success && result.tool?.inputSchema) {
console.log(`✅ Got schema from executor for ${toolMeta.packageName}/${toolMeta.name}`);
return result.tool.inputSchema;
}
return null;
} catch (error) {
console.warn(`⚠️ Schema fetch error for ${toolMeta.packageName}/${toolMeta.name}:`, error);
return null;
}
}
/**
* Create a dynamic tool wrapper that executes via the sandbox executor
*/
@ -221,10 +264,16 @@ async function createDynamicTool(
// Import tool() dynamically to avoid top-level await
const { tool } = await import('ai');
// If inputSchema is missing from the database, fetch it from the executor
let schema = toolMeta.inputSchema;
if (!schema) {
schema = await fetchSchemaFromExecutor(toolMeta);
}
return tool({
description: toolMeta.description,
inputSchema: toolMeta.inputSchema
? jsonSchema(toolMeta.inputSchema as Parameters<typeof jsonSchema>[0])
inputSchema: schema
? jsonSchema(schema as Parameters<typeof jsonSchema>[0])
: jsonSchema({
type: 'object',
properties: {},

View file

@ -86,6 +86,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
provider: agent.provider,
modelId: agent.modelId,
likeCount: agent.likeCount,
forkCount: agent.forkCount,
toolCount: agent._count.tools,
collectionCount: agent._count.collections,
createdAt: agent.createdAt,

View file

@ -84,6 +84,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
name: collection.name,
description: collection.description,
likeCount: collection.likeCount,
forkCount: collection.forkCount,
toolCount: collection._count.tools,
createdAt: collection.createdAt,
createdBy: collection.user,

View file

@ -87,32 +87,36 @@ export async function GET(_request: NextRequest, context: RouteContext) {
// Check if collection is public
if (!question.collection.isPublic) {
return NextResponse.json({ error: 'Question belongs to a private collection' }, { status: 403 });
return NextResponse.json(
{ error: 'Question belongs to a private collection' },
{ status: 403 }
);
}
// Fetch similar questions (based on same skills)
const skillIds = question.skillNodes.map((sn) => sn.skill.id);
const similarQuestions = skillIds.length > 0
? await prisma.skillQuestion.findMany({
where: {
id: { not: id },
collectionId: question.collection.id,
skillNodes: {
some: {
skillId: { in: skillIds },
const similarQuestions =
skillIds.length > 0
? await prisma.skillQuestion.findMany({
where: {
id: { not: id },
collectionId: question.collection.id,
skillNodes: {
some: {
skillId: { in: skillIds },
},
},
},
},
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
question: true,
confidence: true,
createdAt: true,
},
})
: [];
take: 5,
orderBy: { createdAt: 'desc' },
select: {
id: true,
question: true,
confidence: true,
createdAt: true,
},
})
: [];
return NextResponse.json({
success: true,

View file

@ -1,30 +1,23 @@
import { prisma } from '@tpmjs/db';
import { fetchChanges, fetchLatestPackageWithMetadata } from '@tpmjs/npm-client';
import type { TpmjsToolDefinition } from '@tpmjs/types/tpmjs';
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
import { type NextRequest, NextResponse } from 'next/server';
import { env } from '~/env';
import { performHealthCheck } from '~/lib/health-check/health-check-service';
import {
convertJsonSchemaToParameters,
extractToolSchema,
listToolExports,
} from '~/lib/schema-extraction';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300; // 5 minutes max for cron jobs
export const maxDuration = 60;
/**
* POST /api/sync/changes
* Sync tools from NPM changes feed
* Discovery-only sync: monitors NPM changes feed, upserts packages and tools.
* Does NOT call the executor for schema extraction or health checks that's handled by /api/sync/enrich.
*
* This endpoint is called by Vercel Cron (every 2 minutes)
* Called by Vercel Cron (every 4 hours) or GitHub Actions.
* Requires Authorization: Bearer <CRON_SECRET>
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward CRUD operation
export async function POST(request: NextRequest) {
// Verify cron secret for security
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
@ -39,7 +32,6 @@ export async function POST(request: NextRequest) {
const errorMessages: string[] = [];
try {
// Get last checkpoint
const checkpoint = await prisma.syncCheckpoint.findUnique({
where: { source: 'changes-feed' },
});
@ -48,46 +40,37 @@ export async function POST(request: NextRequest) {
? String((checkpoint.checkpoint as { lastSeq?: string })?.lastSeq || '0')
: '0';
// Fetch changes from NPM (limit to 30 per run to allow time for schema extraction)
// Increased limit since we no longer spend time on schema extraction
const changesResult = await fetchChanges({
since: lastSeq,
limit: 30,
limit: 100,
includeDocs: false,
});
// Process each change
for (const change of changesResult.results) {
try {
// Fetch full package metadata with README
const pkg = await fetchLatestPackageWithMetadata(change.id);
// Skip if package not found
if (!pkg) {
skipped++;
continue;
}
// Check if package has tpmjs field
if (!pkg.tpmjs) {
skipped++;
continue;
}
// Validate tpmjs field (supports both new multi-tool and legacy formats)
const validation = validateTpmjsField(pkg.tpmjs);
if (!validation.valid || !validation.packageData || !validation.tools) {
if (!validation.valid || !validation.packageData) {
skipped++;
continue;
}
// Log auto-migration from legacy format
if (validation.wasLegacyFormat) {
console.log(`Auto-migrated legacy package: ${pkg.name}`);
}
// Extract repository URL and GitHub stars
const githubStars: number | null = null;
// Upsert Package record
const packageRecord = await prisma.package.upsert({
where: { npmPackageName: pkg.name },
@ -109,8 +92,8 @@ export async function POST(request: NextRequest) {
tier: validation.tier || 'minimal',
discoveryMethod: 'changes-feed',
isOfficial: pkg.keywords?.includes('tpmjs') || false,
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
githubStars: githubStars,
npmDownloadsLastMonth: 0,
githubStars: null,
},
update: {
npmVersion: pkg.version,
@ -131,53 +114,30 @@ export async function POST(request: NextRequest) {
},
});
// Get existing tools for this package
// For auto-discovery packages, skip tool creation — enrichment will handle it
if (validation.needsAutoDiscovery) {
console.log(
`Package ${pkg.name} needs auto-discovery — enrichment will handle tool creation`
);
processed++;
continue;
}
// Upsert tools from the tpmjs.tools array (manual discovery only)
const toolsToProcess = validation.tools || [];
const existingTools = await prisma.tool.findMany({
where: { packageId: packageRecord.id },
});
// Determine the tools to process
let toolsToProcess: TpmjsToolDefinition[] = validation.tools || [];
let toolDiscoverySource: 'auto' | 'manual' = 'manual';
// If tools need auto-discovery, call the executor to list exports
if (validation.needsAutoDiscovery) {
console.log(`Auto-discovering tools for ${pkg.name}...`);
const exportsResult = await listToolExports(pkg.name, pkg.version, null);
if (exportsResult.success) {
// Convert discovered tools to TpmjsToolDefinition format
toolsToProcess = exportsResult.tools
.filter((t) => t.isValidTool)
.map((t) => ({
name: t.name,
description: t.description,
parameters: undefined,
returns: undefined,
aiAgent: undefined,
}));
toolDiscoverySource = 'auto';
console.log(
`Auto-discovered ${toolsToProcess.length} tools for ${pkg.name}: ${toolsToProcess.map((t) => t.name).join(', ')}`
);
} else {
console.log(`Failed to auto-discover tools for ${pkg.name}: ${exportsResult.error}`);
// Skip this package if we can't discover tools
skipped++;
continue;
}
}
// Upsert each tool
for (const toolDef of toolsToProcess) {
// Get tool name from validated schema
const toolName = toolDef.name;
if (!toolName) {
console.warn(`Skipping tool without name in ${pkg.name}`);
continue;
}
const upsertedTool = await prisma.tool.upsert({
await prisma.tool.upsert({
where: {
packageId_name: {
packageId: packageRecord.id,
@ -194,10 +154,9 @@ export async function POST(request: NextRequest) {
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null, // Will be calculated by metrics sync
// Schema will be extracted below
qualityScore: null,
schemaSource: toolDef.parameters ? 'author' : null,
toolDiscoverySource,
toolDiscoverySource: 'manual',
},
update: {
description: toolDef.description || undefined,
@ -207,52 +166,9 @@ export async function POST(request: NextRequest) {
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
toolDiscoverySource,
toolDiscoverySource: 'manual',
},
});
// Extract schema synchronously from executor
const schemaResult = await extractToolSchema(pkg.name, toolName, pkg.version, null);
if (schemaResult.success) {
// Update tool with extracted schema (and description if not provided)
await prisma.tool.update({
where: { id: upsertedTool.id },
data: {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
inputSchema: schemaResult.inputSchema as any,
// Also update parameters array for backward compatibility
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
schemaSource: 'extracted',
schemaExtractedAt: new Date(),
// Update description if not provided by author
...(!toolDef.description && schemaResult.description
? { description: schemaResult.description }
: {}),
},
});
console.log(`Schema extracted for ${pkg.name}/${toolName}`);
} else {
// Extraction failed - mark schema source appropriately
console.log(
`Schema extraction failed for ${pkg.name}/${toolName}: ${schemaResult.error}`
);
await prisma.tool.update({
where: { id: upsertedTool.id },
data: {
schemaSource: toolDef.parameters ? 'author' : null,
},
});
}
// Trigger health check (non-blocking) for execution testing
performHealthCheck(upsertedTool.id, 'sync').catch((err) => {
console.error(
`Health check failed for ${pkg.name}/${toolName} (${upsertedTool.id}):`,
err
);
});
}
// Delete orphaned tools (tools removed from package.json)
@ -278,7 +194,6 @@ export async function POST(request: NextRequest) {
}
}
// Update checkpoint with new sequence
await prisma.syncCheckpoint.upsert({
where: { source: 'changes-feed' },
create: {
@ -296,7 +211,6 @@ export async function POST(request: NextRequest) {
},
});
// Log sync operation
await prisma.syncLog.create({
data: {
source: 'changes-feed',
@ -330,7 +244,6 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error('Changes feed sync failed:', error);
// Log failed sync
await prisma.syncLog.create({
data: {
source: 'changes-feed',

View file

@ -0,0 +1,261 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { env } from '~/env';
import { performHealthCheck } from '~/lib/health-check/health-check-service';
import {
convertJsonSchemaToParameters,
extractToolSchema,
listToolExports,
} from '~/lib/schema-extraction';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const TIME_BUDGET_MS = 45_000; // Stop starting new work after 45s (leaves 15s buffer)
const RETRY_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour before retrying failed extractions
/**
* POST /api/sync/enrich
* Enrichment queue processor: extracts schemas and runs health checks for tools
* that haven't been enriched yet. Also handles auto-discovery for packages with no tools.
*
* Called by Vercel Cron (every 2 minutes) or GitHub Actions.
* Requires Authorization: Bearer <CRON_SECRET>
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward queue processing
export async function POST(request: NextRequest) {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
if (env.CRON_SECRET && token !== env.CRON_SECRET) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const startTime = Date.now();
let enriched = 0;
let discovered = 0;
let errors = 0;
let skipped = 0;
const errorMessages: string[] = [];
try {
const now = new Date();
const retryCutoff = new Date(now.getTime() - RETRY_COOLDOWN_MS);
// Phase 1: Auto-discover tools for packages that have 0 tools
const packagesNeedingDiscovery = await prisma.package.findMany({
where: {
tools: { none: {} },
},
take: 5,
});
for (const pkg of packagesNeedingDiscovery) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
console.log('Time budget exceeded during auto-discovery phase, stopping');
break;
}
try {
console.log(`Auto-discovering tools for ${pkg.npmPackageName}...`);
const exportsResult = await listToolExports(
pkg.npmPackageName,
pkg.npmVersion,
pkg.env as Record<string, unknown> | null
);
if (!exportsResult.success) {
console.log(
`Failed to auto-discover tools for ${pkg.npmPackageName}: ${exportsResult.error}`
);
errors++;
errorMessages.push(
`Auto-discovery failed for ${pkg.npmPackageName}: ${exportsResult.error}`
);
continue;
}
const validTools = exportsResult.tools.filter((t) => t.isValidTool);
for (const tool of validTools) {
await prisma.tool.upsert({
where: {
packageId_name: {
packageId: pkg.id,
name: tool.name,
},
},
create: {
packageId: pkg.id,
name: tool.name,
description: tool.description || 'No description provided',
qualityScore: null,
schemaSource: null,
toolDiscoverySource: 'auto',
},
update: {
description: tool.description || undefined,
toolDiscoverySource: 'auto',
},
});
}
console.log(
`Auto-discovered ${validTools.length} tools for ${pkg.npmPackageName}: ${validTools.map((t) => t.name).join(', ')}`
);
discovered++;
} catch (error) {
errors++;
const errorMsg = `Auto-discovery error for ${pkg.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errorMessages.push(errorMsg);
console.error(errorMsg);
}
}
// Phase 2: Enrich tools that need schema extraction
const toolsToEnrich = await prisma.tool.findMany({
where: {
schemaSource: null,
OR: [
{ schemaExtractionAttemptAt: null },
{ schemaExtractionAttemptAt: { lt: retryCutoff } },
],
},
include: { package: true },
take: 10, // Fetch a few more than we'll likely process
orderBy: { createdAt: 'asc' }, // Oldest first
});
for (const tool of toolsToEnrich) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
console.log('Time budget exceeded during enrichment phase, stopping');
skipped += toolsToEnrich.length - enriched;
break;
}
try {
// Mark attempt time before starting (prevents concurrent processing)
await prisma.tool.update({
where: { id: tool.id },
data: { schemaExtractionAttemptAt: now },
});
console.log(`Extracting schema for ${tool.package.npmPackageName}/${tool.name}...`);
const schemaResult = await extractToolSchema(
tool.package.npmPackageName,
tool.name,
tool.package.npmVersion,
tool.package.env as Record<string, unknown> | null
);
if (schemaResult.success) {
await prisma.tool.update({
where: { id: tool.id },
data: {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
inputSchema: schemaResult.inputSchema as any,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
schemaSource: 'extracted',
schemaExtractedAt: new Date(),
schemaExtractionError: null,
// Update description if not already set meaningfully
...(tool.description === 'No description provided' && schemaResult.description
? { description: schemaResult.description }
: {}),
},
});
console.log(`Schema extracted for ${tool.package.npmPackageName}/${tool.name}`);
// Trigger health check after successful extraction
performHealthCheck(tool.id, 'enrich').catch((err) => {
console.error(
`Health check failed for ${tool.package.npmPackageName}/${tool.name}:`,
err
);
});
enriched++;
} else {
await prisma.tool.update({
where: { id: tool.id },
data: {
schemaExtractionError: schemaResult.error,
},
});
console.log(
`Schema extraction failed for ${tool.package.npmPackageName}/${tool.name}: ${schemaResult.error}`
);
errors++;
errorMessages.push(`${tool.package.npmPackageName}/${tool.name}: ${schemaResult.error}`);
}
} catch (error) {
errors++;
const errorMsg = `${tool.package.npmPackageName}/${tool.name}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errorMessages.push(errorMsg);
console.error(`Enrichment error: ${errorMsg}`);
}
}
// Log sync operation
await prisma.syncLog.create({
data: {
source: 'enrichment',
status: errors > 0 ? 'partial' : 'success',
processed: enriched + discovered,
skipped,
errors,
message:
errors > 0
? `Enriched ${enriched} tools, discovered ${discovered} packages. Errors: ${errorMessages.slice(0, 3).join('; ')}`
: `Enriched ${enriched} tools, discovered ${discovered} packages`,
metadata: {
durationMs: Date.now() - startTime,
enriched,
discovered,
toolsInQueue: toolsToEnrich.length,
packagesNeedingDiscovery: packagesNeedingDiscovery.length,
},
},
});
return NextResponse.json({
success: true,
data: {
enriched,
discovered,
skipped,
errors,
durationMs: Date.now() - startTime,
errorMessages: errorMessages.slice(0, 5),
},
});
} catch (error) {
console.error('Enrichment sync failed:', error);
await prisma.syncLog.create({
data: {
source: 'enrichment',
status: 'error',
processed: enriched + discovered,
skipped,
errors: errors + 1,
message: error instanceof Error ? error.message : 'Unknown error',
metadata: {
durationMs: Date.now() - startTime,
},
},
});
return NextResponse.json(
{
success: false,
error: 'Enrichment failed',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View file

@ -1,30 +1,23 @@
import { prisma } from '@tpmjs/db';
import { fetchLatestPackageWithMetadata, searchByKeyword } from '@tpmjs/npm-client';
import type { TpmjsToolDefinition } from '@tpmjs/types/tpmjs';
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
import { type NextRequest, NextResponse } from 'next/server';
import { env } from '~/env';
import { performHealthCheck } from '~/lib/health-check/health-check-service';
import {
convertJsonSchemaToParameters,
extractToolSchema,
listToolExports,
} from '~/lib/schema-extraction';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300; // 5 minutes max for cron jobs
export const maxDuration = 60;
/**
* POST /api/sync/keyword
* Sync tools by searching NPM for 'tpmjs' keyword
* Discovery-only sync: searches NPM for 'tpmjs' keyword, upserts packages and tools.
* Does NOT call the executor for schema extraction or health checks that's handled by /api/sync/enrich.
*
* This endpoint is called by Vercel Cron (every 15 minutes)
* Called by Vercel Cron (every 6 hours) or GitHub Actions.
* Requires Authorization: Bearer <CRON_SECRET>
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward CRUD operation
export async function POST(request: NextRequest) {
// Verify cron secret for security
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
@ -40,19 +33,15 @@ export async function POST(request: NextRequest) {
const skippedPackages: Array<{ name: string; author: string; reason: string }> = [];
try {
// Search for packages with 'tpmjs' keyword
const searchResults = await searchByKeyword({
keyword: 'tpmjs',
size: 250, // Get up to 250 packages per sync
size: 250,
});
// Process each package
for (const result of searchResults) {
try {
// Fetch full package metadata with README
const pkg = await fetchLatestPackageWithMetadata(result.package.name);
// Skip if package not found
if (!pkg) {
skipped++;
skippedPackages.push({
@ -63,7 +52,6 @@ export async function POST(request: NextRequest) {
continue;
}
// Extract author name
const authorName =
typeof pkg.author === 'string'
? pkg.author
@ -71,11 +59,10 @@ export async function POST(request: NextRequest) {
? pkg.author.name
: 'unknown';
// Check if package has tpmjs field - if not, we'll auto-discover with defaults
// Validate tpmjs field or use auto-discovery defaults
let validation: ReturnType<typeof validateTpmjsField>;
if (!pkg.tpmjs) {
// No tpmjs field - use auto-discovery with default category
console.log(
`Package ${pkg.name} has tpmjs keyword but no tpmjs field - using auto-discovery`
);
@ -83,14 +70,13 @@ export async function POST(request: NextRequest) {
valid: true,
tier: 'minimal',
packageData: {
category: 'utilities', // Default category for keyword-only packages
category: 'utilities',
},
tools: [],
needsAutoDiscovery: true,
wasLegacyFormat: false,
};
} else {
// Validate tpmjs field (supports both new multi-tool and legacy formats)
validation = validateTpmjsField(pkg.tpmjs);
if (!validation.valid || !validation.packageData) {
skipped++;
@ -102,18 +88,14 @@ export async function POST(request: NextRequest) {
continue;
}
// Log auto-migration from legacy format
if (validation.wasLegacyFormat) {
console.log(`Auto-migrated legacy package: ${pkg.name}`);
}
}
// Extract packageData (guaranteed to exist at this point)
// biome-ignore lint/style/noNonNullAssertion: guaranteed by validation check above
const packageData = validation.packageData!;
// Extract repository URL and GitHub stars
const githubStars: number | null = null;
// Upsert Package record
const packageRecord = await prisma.package.upsert({
where: { npmPackageName: pkg.name },
@ -135,8 +117,8 @@ export async function POST(request: NextRequest) {
tier: validation.tier || 'minimal',
discoveryMethod: 'keyword',
isOfficial: pkg.keywords?.includes('tpmjs') || false,
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
githubStars: githubStars,
npmDownloadsLastMonth: 0,
githubStars: null,
},
update: {
npmVersion: pkg.version,
@ -157,58 +139,30 @@ export async function POST(request: NextRequest) {
},
});
// Get existing tools for this package
// For auto-discovery packages, skip tool creation — enrichment will handle it
if (validation.needsAutoDiscovery) {
console.log(
`Package ${pkg.name} needs auto-discovery — enrichment will handle tool creation`
);
processed++;
continue;
}
// Upsert tools from the tpmjs.tools array (manual discovery only)
const toolsToProcess = validation.tools || [];
const existingTools = await prisma.tool.findMany({
where: { packageId: packageRecord.id },
});
// Determine the tools to process
let toolsToProcess: TpmjsToolDefinition[] = validation.tools || [];
let toolDiscoverySource: 'auto' | 'manual' = 'manual';
// If tools need auto-discovery, call the executor to list exports
if (validation.needsAutoDiscovery) {
console.log(`Auto-discovering tools for ${pkg.name}...`);
const exportsResult = await listToolExports(pkg.name, pkg.version, null);
if (exportsResult.success) {
// Convert discovered tools to TpmjsToolDefinition format
toolsToProcess = exportsResult.tools
.filter((t) => t.isValidTool)
.map((t) => ({
name: t.name,
description: t.description,
parameters: undefined,
returns: undefined,
aiAgent: undefined,
}));
toolDiscoverySource = 'auto';
console.log(
`Auto-discovered ${toolsToProcess.length} tools for ${pkg.name}: ${toolsToProcess.map((t) => t.name).join(', ')}`
);
} else {
console.log(`Failed to auto-discover tools for ${pkg.name}: ${exportsResult.error}`);
// Skip this package if we can't discover tools
skipped++;
skippedPackages.push({
name: pkg.name,
author: authorName,
reason: `auto-discovery failed: ${exportsResult.error}`,
});
continue;
}
}
// Upsert each tool
for (const toolDef of toolsToProcess) {
// Get tool name from validated schema
const toolName = toolDef.name;
if (!toolName) {
console.warn(`Skipping tool without name in ${pkg.name}`);
continue;
}
const upsertedTool = await prisma.tool.upsert({
await prisma.tool.upsert({
where: {
packageId_name: {
packageId: packageRecord.id,
@ -225,10 +179,9 @@ export async function POST(request: NextRequest) {
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null, // Will be calculated by metrics sync
// Schema will be extracted below
qualityScore: null,
schemaSource: toolDef.parameters ? 'author' : null,
toolDiscoverySource,
toolDiscoverySource: 'manual',
},
update: {
description: toolDef.description || undefined,
@ -238,52 +191,9 @@ export async function POST(request: NextRequest) {
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
toolDiscoverySource,
toolDiscoverySource: 'manual',
},
});
// Extract schema synchronously from executor
const schemaResult = await extractToolSchema(pkg.name, toolName, pkg.version, null);
if (schemaResult.success) {
// Update tool with extracted schema (and description if not provided)
await prisma.tool.update({
where: { id: upsertedTool.id },
data: {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
inputSchema: schemaResult.inputSchema as any,
// Also update parameters array for backward compatibility
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
schemaSource: 'extracted',
schemaExtractedAt: new Date(),
// Update description if not provided by author
...(!toolDef.description && schemaResult.description
? { description: schemaResult.description }
: {}),
},
});
console.log(`Schema extracted for ${pkg.name}/${toolName}`);
} else {
// Extraction failed - mark schema source appropriately
console.log(
`Schema extraction failed for ${pkg.name}/${toolName}: ${schemaResult.error}`
);
await prisma.tool.update({
where: { id: upsertedTool.id },
data: {
schemaSource: toolDef.parameters ? 'author' : null,
},
});
}
// Trigger health check (non-blocking) for execution testing
performHealthCheck(upsertedTool.id, 'sync').catch((err) => {
console.error(
`Health check failed for ${pkg.name}/${toolName} (${upsertedTool.id}):`,
err
);
});
}
// Delete orphaned tools (tools removed from package.json)
@ -309,7 +219,6 @@ export async function POST(request: NextRequest) {
}
}
// Update checkpoint with last run timestamp
await prisma.syncCheckpoint.upsert({
where: { source: 'keyword-search' },
create: {
@ -327,7 +236,6 @@ export async function POST(request: NextRequest) {
},
});
// Log sync operation
await prisma.syncLog.create({
data: {
source: 'keyword-search',
@ -354,14 +262,13 @@ export async function POST(request: NextRequest) {
errors,
packagesFound: searchResults.length,
durationMs: Date.now() - startTime,
errorMessages: errorMessages.slice(0, 5), // Include first 5 error messages
skippedPackages: skippedPackages, // Include all skipped package names
errorMessages: errorMessages.slice(0, 5),
skippedPackages: skippedPackages,
},
});
} catch (error) {
console.error('Keyword search sync failed:', error);
// Log failed sync
await prisma.syncLog.create({
data: {
source: 'keyword-search',

View file

@ -7,16 +7,17 @@ export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300; // 5 minutes max for cron jobs
const BATCH_SIZE = 5;
/**
* POST /api/sync/metrics
* Update download stats and quality scores for all packages and tools
*
* This endpoint is called by Vercel Cron (every hour)
* This endpoint is called by Vercel Cron (daily)
* Requires Authorization: Bearer <CRON_SECRET>
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward CRUD operation
export async function POST(request: NextRequest) {
// Verify cron secret for security
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
@ -31,62 +32,69 @@ export async function POST(request: NextRequest) {
const errorMessages: string[] = [];
try {
// Get all packages with their tools from database
const packages = await prisma.package.findMany({
include: {
tools: true,
},
});
// Process each package
for (const pkg of packages) {
try {
// Fetch download stats from NPM (package-level metric)
const downloads = await fetchDownloadStats(pkg.npmPackageName);
// Process packages in batches of BATCH_SIZE concurrently
for (let i = 0; i < packages.length; i += BATCH_SIZE) {
const batch = packages.slice(i, i + BATCH_SIZE);
// Fetch GitHub stars if repository is available
const githubStars = await fetchGitHubStarsFromRepository(
pkg.npmRepository as { type?: string; url?: string } | string | null
);
const results = await Promise.allSettled(
batch.map(async (pkg) => {
// Fetch downloads and GitHub stars in parallel for each package
const [downloads, githubStars] = await Promise.all([
fetchDownloadStats(pkg.npmPackageName),
fetchGitHubStarsFromRepository(
pkg.npmRepository as { type?: string; url?: string } | string | null
),
]);
// Update package metrics
await prisma.package.update({
where: { id: pkg.id },
data: {
npmDownloadsLastMonth: downloads,
githubStars,
},
});
// Calculate and update quality score for each tool in this package
for (const tool of pkg.tools) {
const qualityScore = calculateQualityScore({
tier: pkg.tier, // Tier is at package level
downloads, // Package downloads
githubStars, // Use freshly fetched stars
hasParameters: !!tool.parameters,
hasReturns: !!tool.returns,
hasAiAgent: !!tool.aiAgent,
});
await prisma.tool.update({
where: { id: tool.id },
await prisma.package.update({
where: { id: pkg.id },
data: {
qualityScore,
npmDownloadsLastMonth: downloads,
githubStars,
},
});
}
processed++;
} catch (error) {
errors++;
const errorMsg = `Failed to process ${pkg.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errorMessages.push(errorMsg);
console.error(errorMsg);
// Calculate and update quality score for each tool in this package
for (const tool of pkg.tools) {
const qualityScore = calculateQualityScore({
tier: pkg.tier,
downloads,
githubStars,
hasParameters: !!tool.parameters,
hasReturns: !!tool.returns,
hasAiAgent: !!tool.aiAgent,
});
await prisma.tool.update({
where: { id: tool.id },
data: {
qualityScore,
},
});
}
return pkg.npmPackageName;
})
);
for (const result of results) {
if (result.status === 'fulfilled') {
processed++;
} else {
errors++;
const errorMsg = `Failed to process package: ${result.reason instanceof Error ? result.reason.message : 'Unknown error'}`;
errorMessages.push(errorMsg);
console.error(errorMsg);
}
}
}
// Update checkpoint with last run timestamp
await prisma.syncCheckpoint.upsert({
where: { source: 'metrics' },
create: {
@ -106,7 +114,6 @@ export async function POST(request: NextRequest) {
},
});
// Log sync operation
await prisma.syncLog.create({
data: {
source: 'metrics',
@ -140,7 +147,6 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error('Metrics sync failed:', error);
// Log failed sync
await prisma.syncLog.create({
data: {
source: 'metrics',

View file

@ -10,6 +10,7 @@ export const maxDuration = 60;
* Captures a daily snapshot of registry statistics for historical tracking.
* Should be run once per day via cron.
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cron handler with many parallel queries
export async function POST(request: NextRequest) {
const startTime = Date.now();
@ -75,6 +76,12 @@ export async function POST(request: NextRequest) {
// Daily health checks
dailyHealthChecks,
// Social proof
activeDevsResult,
publicCollectionsCount,
publicAgentsCount,
totalSimulationsCount,
// Quality distribution
qualityDistribution,
] = await Promise.all([
@ -142,6 +149,17 @@ export async function POST(request: NextRequest) {
where: { createdAt: { gte: yesterday, lt: today } },
}),
// Social proof fields
prisma.userActivity.groupBy({
by: ['userId'],
where: {
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
},
}),
prisma.collection.count({ where: { isPublic: true } }),
prisma.agent.count({ where: { isPublic: true } }),
prisma.simulation.count(),
// Quality score distribution
prisma.$queryRaw<{ bucket: string; count: bigint }[]>`
SELECT
@ -241,6 +259,12 @@ export async function POST(request: NextRequest) {
// Categories
categories,
// Social proof
activeDevs7d: activeDevsResult.length,
totalCollections: publicCollectionsCount,
totalAgents: publicAgentsCount,
totalSimulations: totalSimulationsCount,
},
});

View file

@ -0,0 +1,86 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* POST /api/sync/view-rollup
* Daily cron: aggregates PageView counts into denormalized viewCount fields
* on Tool, Collection, and Agent models.
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cron handler with sequential entity type processing
export async function POST(request: NextRequest) {
const startTime = Date.now();
// Verify cron secret
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// Aggregate views by entity type and entity ID (all-time sum)
const entityTypes = ['tool', 'collection', 'agent'] as const;
let totalUpdated = 0;
for (const entityType of entityTypes) {
// Get aggregated view counts per entity
const viewCounts = await prisma.pageView.groupBy({
by: ['entityId'],
where: { entityType },
_sum: { viewCount: true },
});
// Update denormalized viewCount on each entity
for (const vc of viewCounts) {
const totalViews = vc._sum.viewCount || 0;
if (totalViews === 0) continue;
try {
if (entityType === 'tool') {
await prisma.tool.update({
where: { id: vc.entityId },
data: { viewCount: totalViews },
});
} else if (entityType === 'collection') {
await prisma.collection.update({
where: { id: vc.entityId },
data: { viewCount: totalViews },
});
} else if (entityType === 'agent') {
await prisma.agent.update({
where: { id: vc.entityId },
data: { viewCount: totalViews },
});
}
totalUpdated++;
} catch {
// Entity may have been deleted - skip silently
}
}
}
const durationMs = Date.now() - startTime;
return NextResponse.json({
success: true,
data: {
totalUpdated,
durationMs,
},
});
} catch (error) {
console.error('[sync/view-rollup] Error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View file

@ -235,6 +235,7 @@ export async function GET(request: NextRequest) {
id: tool.id,
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
qualityScore: tool.qualityScore,
importHealth: tool.importHealth,
executionHealth: tool.executionHealth,

View file

@ -0,0 +1,87 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { getClientId } from '~/lib/rate-limit';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 10;
const VALID_ENTITY_TYPES = ['tool', 'collection', 'agent'] as const;
// Simple in-memory dedup: 1 view per entity per IP per hour
const recentViews = new Map<string, number>();
// Clean up every 10 minutes
setInterval(() => {
const now = Date.now();
for (const [key, timestamp] of recentViews) {
if (now - timestamp > 3600_000) {
recentViews.delete(key);
}
}
// Prevent unbounded growth
if (recentViews.size > 50_000) {
recentViews.clear();
}
}, 600_000);
/**
* POST /api/track/view
* Fire-and-forget view tracking. Upserts into PageView with daily bucket.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { entityType, entityId } = body;
// Validate input
if (!entityType || !entityId) {
return NextResponse.json({ error: 'Missing entityType or entityId' }, { status: 400 });
}
if (!VALID_ENTITY_TYPES.includes(entityType)) {
return NextResponse.json({ error: 'Invalid entityType' }, { status: 400 });
}
// Rate-limit: 1 view per entity per IP per hour
const clientId = getClientId(request);
const dedupKey = `${clientId}:${entityType}:${entityId}`;
const lastView = recentViews.get(dedupKey);
if (lastView && Date.now() - lastView < 3600_000) {
return NextResponse.json({ ok: true, deduped: true });
}
recentViews.set(dedupKey, Date.now());
// Today's date bucket (midnight UTC)
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
// Upsert page view (fire-and-forget style, don't await in production but we need to for correctness)
await prisma.pageView.upsert({
where: {
entityType_entityId_date: {
entityType,
entityId,
date: today,
},
},
create: {
entityType,
entityId,
date: today,
viewCount: 1,
},
update: {
viewCount: { increment: 1 },
},
});
return NextResponse.json({ ok: true });
} catch (error) {
// Silently fail - view tracking should never break the user experience
console.error('[track/view] Error:', error);
return NextResponse.json({ ok: true });
}
}

View file

@ -21,6 +21,7 @@ interface PublicCollection {
name: string;
description: string | null;
likeCount: number;
forkCount: number;
toolCount: number;
createdAt: string;
createdBy: {
@ -53,6 +54,7 @@ function truncateText(text: string, maxLength: number): string {
return `${text.slice(0, maxLength).trim()}...`;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large page component with table rendering
export default function PublicCollectionsPage(): React.ReactElement {
const [collections, setCollections] = useState<PublicCollection[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -126,8 +128,9 @@ export default function PublicCollectionsPage(): React.ReactElement {
() => (
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
<th className="px-4 py-3 w-[250px]">Name</th>
<th className="px-4 py-3 w-[300px]">Description</th>
<th className="px-4 py-3 w-[250px]">Description</th>
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
<th className="px-4 py-3 w-[70px] text-center">Forks</th>
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
<th className="px-4 py-3 w-[150px]">Creator</th>
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
@ -141,7 +144,11 @@ export default function PublicCollectionsPage(): React.ReactElement {
<>
<td className="px-4 py-3">
<Link
href={collection.createdBy.username ? `/${collection.createdBy.username}/collections/${collection.slug}` : `/collections/${collection.id}`}
href={
collection.createdBy.username
? `/${collection.createdBy.username}/collections/${collection.slug}`
: `/collections/${collection.id}`
}
className="font-semibold text-foreground hover:text-primary group-hover:text-primary transition-colors"
>
{collection.name}
@ -155,6 +162,9 @@ export default function PublicCollectionsPage(): React.ReactElement {
{collection.toolCount}
</Badge>
</td>
<td className="px-4 py-3 text-center text-sm text-foreground-secondary">
{collection.forkCount > 0 ? collection.forkCount : '—'}
</td>
<td className="px-4 py-3 text-center">
<LikeButton
entityType="collection"

View file

@ -713,7 +713,7 @@ export default function AgentChatPage(): React.ReactElement {
className={`max-w-[85%] rounded-lg p-4 ${
message.role === 'USER'
? 'bg-primary text-primary-foreground'
: 'bg-surface border border-dashed border-border'
: 'bg-surface text-foreground border border-dashed border-border'
}`}
>
{message.role === 'USER' ? (

View file

@ -369,16 +369,18 @@ export default function CollectionDetailPage(): React.ReactElement {
const httpUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/http`;
const sseUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/sse`;
// Claude Code CLI command (correct arg order: options before name and url)
const claudeCodeCommand = `claude mcp add ${collection.slug} ${httpUrl} -t http -H "Authorization: Bearer YOUR_TPMJS_API_KEY"`;
// Claude Desktop native HTTP config
const configSnippet = `{
"mcpServers": {
"${collection.slug}": {
"command": "npx",
"args": [
"mcp-remote",
"${httpUrl}",
"--header",
"Authorization: Bearer YOUR_TPMJS_API_KEY"
]
"type": "http",
"url": "${httpUrl}",
"headers": {
"Authorization": "Bearer YOUR_TPMJS_API_KEY"
}
}
}
}`;
@ -573,7 +575,35 @@ export default function CollectionDetailPage(): React.ReactElement {
<McpUrlDisplay url={sseUrl} label="SSE Transport" sublabel="streaming" />
</div>
{/* Claude Code CLI command */}
<div className="mt-6 pt-4 border-t border-border">
<h4 className="text-sm font-medium text-foreground mb-2">Add to Claude Code</h4>
<div className="relative">
<pre className="p-4 bg-surface-secondary border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all">
{claudeCodeCommand}
</pre>
<Button
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(claudeCodeCommand)}
className="absolute top-2 right-2"
>
<Icon icon="copy" size="xs" />
</Button>
</div>
<p className="mt-2 text-xs text-foreground-tertiary">
Replace <code className="font-mono">YOUR_TPMJS_API_KEY</code> with your{' '}
<Link
href="/dashboard/settings/tpmjs-api-keys"
className="text-primary hover:underline"
>
TPMJS API key
</Link>
. Then run <code className="font-mono">/mcp</code> in Claude Code to verify.
</p>
</div>
<div className="mt-4">
<button
type="button"
onClick={() => setShowClaudeConfig(!showClaudeConfig)}

View file

@ -113,7 +113,7 @@ export default function LikedCollectionsPage(): React.ReactElement {
<p className="text-foreground-secondary mb-4">
Browse public collections and click the heart icon to save your favorites
</p>
<Link href="/collections">
<Link href="/">
<Button>Browse Collections</Button>
</Link>
</div>

View file

@ -1,4 +1,3 @@
import { Button } from '@tpmjs/ui/Button/Button';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import type { Metadata } from 'next';
@ -10,7 +9,7 @@ import { AppHeader } from '~/components/AppHeader';
export const metadata: Metadata = {
title: 'Custom Executors - TPMJS',
description:
'Learn how to deploy and configure custom executors for running TPMJS tools on your own infrastructure.',
'Deploy your own executor to run TPMJS tools on your own infrastructure with full control and privacy.',
};
const executeToolExample = `// POST /execute-tool
@ -46,69 +45,39 @@ export default function ExecutorsDocsPage(): React.ReactElement {
<div className="mb-12">
<h1 className="text-3xl font-bold text-foreground mb-4">Custom Executors</h1>
<p className="text-lg text-foreground-secondary">
Deploy your own executor to run TPMJS tools on your own infrastructure.
Deploy your own executor to run TPMJS tools on your infrastructure with full control
over environment, secrets, and data.
</p>
</div>
{/* Quick Start Banner */}
<section className="mb-12">
<Link
href="/docs/tutorials/custom-executor"
className="block p-4 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border border-primary/30 rounded-lg hover:border-primary/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="text-3xl">🚀</div>
<div className="flex-1">
<p className="font-medium text-foreground">
New to custom executors? Start with the tutorial
</p>
<p className="text-sm text-foreground-secondary">
Deploy your own executor in 10 minutes with our step-by-step guide
</p>
</div>
<Icon icon="chevronRight" className="w-5 h-5 text-primary" />
</div>
</Link>
</section>
{/* Overview Section */}
{/* What is an Executor */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">What is an Executor?</h2>
<p className="text-foreground-secondary mb-4">
An executor is a service that runs TPMJS tools. When you use a collection or agent,
TPMJS sends tool execution requests to an executor, which dynamically loads and runs
the tool code.
TPMJS sends tool execution requests to an executor, which dynamically loads the npm
package and calls the tool&apos;s{' '}
<code className="px-1 bg-surface rounded">execute()</code> function.
</p>
<p className="text-foreground-secondary mb-6">
By default, TPMJS uses a shared executor. You can deploy your own for:
<p className="text-foreground-secondary">
By default, TPMJS uses a shared executor. Deploying your own gives you complete
control over the execution environment.
</p>
</section>
{/* Benefits Grid */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">
Why Deploy Your Own Executor?
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="folder" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">Full Control</h3>
</div>
<p className="text-sm text-foreground-secondary">
Run tools on your own infrastructure with complete control over the execution
environment.
</p>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="globe" className="w-5 h-5 text-primary" />
<Icon icon="key" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">Privacy</h3>
</div>
<p className="text-sm text-foreground-secondary">
Keep tool execution data on your own servers. No data leaves your infrastructure.
</p>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="clock" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">Performance</h3>
</div>
<p className="text-sm text-foreground-secondary">
Deploy in regions closest to your users for lower latency tool execution.
Keep tool execution data on your own servers. No data passes through TPMJS.
</p>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
@ -117,68 +86,279 @@ export default function ExecutorsDocsPage(): React.ReactElement {
<h3 className="font-medium text-foreground">Custom Environment</h3>
</div>
<p className="text-sm text-foreground-secondary">
Inject your own environment variables, secrets, and configuration into tool
execution.
Inject your own API keys, database connections, and secrets into tool execution.
</p>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="folder" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">Full Control</h3>
</div>
<p className="text-sm text-foreground-secondary">
Choose your infrastructure, scale resources, and customize the execution
environment.
</p>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Icon icon="clock" className="w-5 h-5 text-primary" />
<h3 className="font-medium text-foreground">No Timeouts</h3>
</div>
<p className="text-sm text-foreground-secondary">
Run long-running tools without hitting shared executor time limits.
</p>
</div>
</div>
</section>
{/* Deploy Section */}
{/* Choose Your Platform */}
<section id="deploy" className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">
Deploy Your Own Executor
</h2>
<h2 className="text-2xl font-semibold text-foreground mb-4">Choose Your Platform</h2>
<p className="text-foreground-secondary mb-6">
The fastest way to get started is to deploy our template to Vercel with one click:
We provide deployment templates for multiple platforms. Choose the one that fits your
needs:
</p>
<div className="mb-6">
<a
href="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"
target="_blank"
rel="noopener noreferrer"
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Railway Card */}
<Link
href="/docs/executors/railway"
className="group p-6 bg-surface border border-border rounded-lg hover:border-primary/50 transition-colors"
>
<Button size="lg">
<Icon icon="externalLink" className="w-4 h-4 mr-2" />
Deploy to Vercel
</Button>
</a>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-[#0B0D0E] rounded-lg flex items-center justify-center">
<svg
className="w-7 h-7"
viewBox="0 0 24 24"
fill="none"
role="img"
aria-labelledby="railway-logo-title"
>
<title id="railway-logo-title">Railway logo</title>
<path
d="M.113 12.611c-.139.312-.107.633.092.917.156.222.38.35.651.357h5.523a.28.28 0 00.216-.1c.07-.078.135-.22.2-.426.224-.718.428-1.09.65-1.492l.003-.005c.247-.448.51-.924.817-1.762.26-.712.296-1.371.113-2.008-.178-.62-.627-1.213-1.406-1.823a.248.248 0 00-.23-.044c-.078.025-.14.08-.178.157l-5.46 4.67a1.017 1.017 0 00-.287.41z"
fill="#fff"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
Railway
</h3>
<p className="text-sm text-foreground-secondary mt-1">
Always-on with auto-scaling and a $5/month free tier. One-click deploy.
</p>
<div className="flex flex-wrap gap-2 mt-3">
<span className="px-2 py-0.5 text-xs bg-success/10 text-success rounded">
Official
</span>
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
Free tier
</span>
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
Auto-scaling
</span>
</div>
</div>
<Icon
icon="chevronRight"
className="w-5 h-5 text-foreground-tertiary group-hover:text-primary transition-colors"
/>
</div>
</Link>
{/* Unsandbox Card */}
<Link
href="/docs/executors/unsandbox"
className="group p-6 bg-surface border border-border rounded-lg hover:border-primary/50 transition-colors"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-lg flex items-center justify-center text-white font-bold text-lg">
un
</div>
<div className="flex-1">
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
Unsandbox
</h3>
<p className="text-sm text-foreground-secondary mt-1">
Always-on container execution with automatic HTTPS. Deploy with one CLI
command.
</p>
<div className="flex flex-wrap gap-2 mt-3">
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
No cold starts
</span>
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
Unlimited runtime
</span>
</div>
</div>
<Icon
icon="chevronRight"
className="w-5 h-5 text-foreground-tertiary group-hover:text-primary transition-colors"
/>
</div>
</Link>
{/* Vercel Card */}
<Link
href="/docs/executors/vercel"
className="group p-6 bg-surface border border-border rounded-lg hover:border-primary/50 transition-colors"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
viewBox="0 0 76 65"
fill="currentColor"
role="img"
aria-labelledby="vercel-logo-title"
>
<title id="vercel-logo-title">Vercel logo</title>
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
Vercel
</h3>
<p className="text-sm text-foreground-secondary mt-1">
Serverless execution with VM-level isolation using Vercel Sandbox.
</p>
<div className="flex flex-wrap gap-2 mt-3">
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
One-click deploy
</span>
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
Pay-per-use
</span>
</div>
</div>
<Icon
icon="chevronRight"
className="w-5 h-5 text-foreground-tertiary group-hover:text-primary transition-colors"
/>
</div>
</Link>
</div>
<p className="text-sm text-foreground-tertiary">
After deployment, you&apos;ll get a URL like{' '}
<code className="px-1.5 py-0.5 bg-surface rounded text-foreground-secondary">
https://tpmjs-executor.vercel.app
</code>
<p className="text-sm text-foreground-tertiary mt-4">
You can also build your own executor on any platform that runs Node.js. Just implement
the API specification below.
</p>
</section>
{/* Comparison Table */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Platform Comparison</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-3 pr-4 font-medium text-foreground">Feature</th>
<th className="text-left py-3 px-4 font-medium text-foreground">Railway</th>
<th className="text-left py-3 px-4 font-medium text-foreground">Unsandbox</th>
<th className="text-left py-3 pl-4 font-medium text-foreground">Vercel</th>
</tr>
</thead>
<tbody className="text-foreground-secondary">
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Deploy method</td>
<td className="py-3 px-4">One-click / CLI</td>
<td className="py-3 px-4">CLI command</td>
<td className="py-3 pl-4">One-click button</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Isolation</td>
<td className="py-3 px-4">Container-level</td>
<td className="py-3 px-4">Container-level</td>
<td className="py-3 pl-4">VM-level (Sandbox)</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Cold starts</td>
<td className="py-3 px-4">
<span className="text-success">None (always-on)</span>
</td>
<td className="py-3 px-4">
<span className="text-success">None (always-on)</span>
</td>
<td className="py-3 pl-4">Yes (serverless)</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Max runtime</td>
<td className="py-3 px-4">
<span className="text-success">Unlimited</span>
</td>
<td className="py-3 px-4">
<span className="text-success">Unlimited</span>
</td>
<td className="py-3 pl-4">45min (Hobby) / 5hr (Pro)</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Free tier</td>
<td className="py-3 px-4">
<span className="text-success">$5/month credit</span>
</td>
<td className="py-3 px-4">None</td>
<td className="py-3 pl-4">Limited</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Pricing</td>
<td className="py-3 px-4">Per usage</td>
<td className="py-3 px-4">Per uptime</td>
<td className="py-3 pl-4">Per compute time</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Auto-scaling</td>
<td className="py-3 px-4">
<span className="text-success">Yes</span>
</td>
<td className="py-3 px-4">Manual</td>
<td className="py-3 pl-4">Yes</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Custom domains</td>
<td className="py-3 px-4">Yes</td>
<td className="py-3 px-4">Yes</td>
<td className="py-3 pl-4">Yes</td>
</tr>
<tr>
<td className="py-3 pr-4">Docker support</td>
<td className="py-3 px-4">Yes</td>
<td className="py-3 px-4">Yes</td>
<td className="py-3 pl-4">No</td>
</tr>
</tbody>
</table>
</div>
</section>
{/* Configuration Section */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Configuration</h2>
<h2 className="text-2xl font-semibold text-foreground mb-4">
Connecting to Your Executor
</h2>
<p className="text-foreground-secondary mb-4">
Once you have your executor deployed, configure your collections or agents to use it:
Once deployed, configure your collections or agents to use your executor:
</p>
<ol className="list-decimal list-inside text-foreground-secondary space-y-3 mb-6">
<li>Go to your collection or agent settings</li>
<li>
In the &quot;Executor Configuration&quot; section, select &quot;Custom
Executor&quot;
</li>
<li>In &quot;Executor Configuration&quot;, select &quot;Custom Executor&quot;</li>
<li>
Enter your executor URL (e.g.,{' '}
<code className="px-1.5 py-0.5 bg-surface rounded">
https://tpmjs-executor.vercel.app
https://my-executor.on.unsandbox.com
</code>
)
</li>
<li>Optionally add an API key if your executor requires authentication</li>
<li>Click &quot;Verify Connection&quot; to test the configuration</li>
<li>Add your API key if authentication is enabled</li>
<li>Click &quot;Verify Connection&quot; to test</li>
</ol>
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg">
<p className="text-sm text-warning">
<strong>Security tip:</strong> Set the{' '}
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> environment
variable in your Vercel project to require authentication for all requests.
<strong>Security:</strong> Always set{' '}
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> to require
authentication. Without it, anyone can execute tools on your executor.
</p>
</div>
</section>
@ -188,7 +368,10 @@ export default function ExecutorsDocsPage(): React.ReactElement {
<h2 className="text-2xl font-semibold text-foreground mb-4">
Executor API Specification
</h2>
<p className="text-foreground-secondary mb-6">All executors must implement this API:</p>
<p className="text-foreground-secondary mb-6">
All executors must implement these endpoints. Use this spec if building a custom
executor.
</p>
{/* POST /execute-tool */}
<div className="mb-8">
@ -197,7 +380,9 @@ export default function ExecutorsDocsPage(): React.ReactElement {
/execute-tool
</h3>
<p className="text-foreground-secondary mb-4">
Execute a TPMJS tool with the provided parameters.
Execute a TPMJS tool. The executor should install the npm package, find the named
export, and call its{' '}
<code className="px-1 bg-surface rounded">execute(params)</code> function.
</p>
<div className="space-y-4">
<div>
@ -217,22 +402,33 @@ export default function ExecutorsDocsPage(): React.ReactElement {
<code className="px-2 py-1 bg-success/10 text-success rounded">GET</code> /health
</h3>
<p className="text-foreground-secondary mb-4">
Check executor health status. Used by TPMJS to verify the executor is reachable.
Health check endpoint. TPMJS uses this to verify the executor is reachable and
working.
</p>
<div>
<p className="text-sm font-medium text-foreground mb-2">Response:</p>
<CodeBlock language="json" code={healthExample} />
</div>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<p className="text-sm text-foreground-secondary">
<strong>Note:</strong> Both{' '}
<code className="px-1 bg-surface-secondary rounded">/api/health</code> and{' '}
<code className="px-1 bg-surface-secondary rounded">/health</code> paths should work
(same for <code className="px-1 bg-surface-secondary rounded">/execute-tool</code>).
Our templates support both.
</p>
</div>
</section>
{/* Cascade Section */}
{/* Executor Cascade */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Executor Cascade</h2>
<p className="text-foreground-secondary mb-4">
Executor configuration follows a cascade resolution order:
When a tool is executed, TPMJS resolves which executor to use in this order:
</p>
<div className="flex items-center gap-2 text-foreground-secondary mb-4">
<div className="flex items-center gap-2 text-foreground-secondary mb-4 flex-wrap">
<span className="px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
Agent Config
</span>
@ -255,35 +451,47 @@ export default function ExecutorsDocsPage(): React.ReactElement {
</ul>
</section>
{/* FAQ Section */}
{/* FAQ */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">FAQ</h2>
<div className="space-y-6">
<div>
<h3 className="font-medium text-foreground mb-2">Can I use any cloud provider?</h3>
<h3 className="font-medium text-foreground mb-2">
Which platform should I choose?
</h3>
<p className="text-foreground-secondary text-sm">
Yes! While we provide a Vercel template, you can deploy an executor anywhere that
can run Node.js and expose an HTTP endpoint. The executor just needs to implement
the API specification above.
<strong>Railway</strong> is our official recommendation. It offers one-click
deployment, no cold starts, auto-scaling, and a generous $5/month free tier. Use{' '}
<strong>Unsandbox</strong> if you prefer CLI deployment, or{' '}
<strong>Vercel</strong> if you&apos;re already on Vercel and prefer pay-per-use
serverless pricing.
</p>
</div>
<div>
<h3 className="font-medium text-foreground mb-2">What about timeouts?</h3>
<h3 className="font-medium text-foreground mb-2">Can I use other platforms?</h3>
<p className="text-foreground-secondary text-sm">
The default timeout for tool execution is 30 seconds. On Vercel&apos;s free tier,
you get up to 10 seconds per request. For longer-running tools, consider deploying
to a platform with higher timeout limits.
Yes! Any platform that runs Node.js and exposes HTTP endpoints works. AWS Lambda,
Google Cloud Run, Render, Fly.iojust implement the API specification above.
</p>
</div>
<div>
<h3 className="font-medium text-foreground mb-2">How do tools get loaded?</h3>
<p className="text-foreground-secondary text-sm">
Tools are dynamically imported from{' '}
<Link href="https://esm.sh" className="text-primary hover:underline">
esm.sh
</Link>
, a CDN for npm packages. The executor fetches the package, finds the tool export,
and calls its <code className="px-1 bg-surface rounded">execute()</code> function.
The executor runs <code className="px-1 bg-surface rounded">npm install</code> for
the requested package, then dynamically imports it and calls the tool&apos;s{' '}
<code className="px-1 bg-surface rounded">execute()</code> function. Each
execution uses a fresh temporary directory.
</p>
</div>
<div>
<h3 className="font-medium text-foreground mb-2">
Are environment variables secure?
</h3>
<p className="text-foreground-secondary text-sm">
Yes. Environment variables are stored encrypted by the platform (Vercel/Unsandbox)
and only available during execution. You can also pass per-request environment
variables in the <code className="px-1 bg-surface rounded">env</code> field of the
execute-tool request.
</p>
</div>
</div>
@ -302,16 +510,22 @@ export default function ExecutorsDocsPage(): React.ReactElement {
target="_blank"
rel="noopener noreferrer"
>
<Button variant="secondary" size="sm">
<button
type="button"
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-surface-secondary hover:bg-surface-tertiary text-foreground transition-colors"
>
<Icon icon="github" className="w-4 h-4 mr-2" />
Open an Issue
</Button>
</button>
</a>
<a href="https://discord.gg/tpmjs" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="sm">
<button
type="button"
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md hover:bg-surface text-foreground-secondary transition-colors"
>
<Icon icon="discord" className="w-4 h-4 mr-2" />
Join Discord
</Button>
</button>
</a>
</div>
</section>

View file

@ -0,0 +1,442 @@
import { Button } from '@tpmjs/ui/Button/Button';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import type { Metadata } from 'next';
import Link from 'next/link';
import { AppFooter } from '~/components/AppFooter';
import { AppHeader } from '~/components/AppHeader';
export const metadata: Metadata = {
title: 'Deploy to Railway - Custom Executors - TPMJS',
description:
'Deploy a TPMJS executor to Railway with one click. Always-on, auto-scaling, with a generous free tier.',
};
const healthCheck = `curl https://your-executor.up.railway.app/health`;
const healthResponse = `{
"status": "ok",
"version": "1.0.0",
"info": {
"runtime": "railway",
"timestamp": "2024-01-01T00:00:00.000Z",
"region": "us-west1"
}
}`;
const executeExample = `curl -X POST https://your-executor.up.railway.app/execute-tool \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer your-api-key" \\
-d '{
"packageName": "@tpmjs/hello",
"name": "helloWorldTool",
"version": "latest",
"params": { "includeTimestamp": true }
}'`;
const cliDeploy = `# Clone the template
git clone https://github.com/tpmjs/tpmjs.git
cd tpmjs/templates/railway-executor
# Install Railway CLI
npm install -g @railway/cli
# Login to Railway
railway login
# Create a new project and deploy
railway init
railway up`;
const envVars = `# Set environment variables via CLI
railway variables set EXECUTOR_API_KEY=your-secure-key
railway variables set OPENAI_API_KEY=sk-xxx
railway variables set DATABASE_URL=postgres://...`;
const localDev = `# Clone the repository
git clone https://github.com/tpmjs/tpmjs.git
cd tpmjs/templates/railway-executor
# Run locally
PORT=3000 node index.js
# Test health endpoint
curl http://localhost:3000/health`;
const dockerDeploy = `# Build the image
docker build -t tpmjs-executor .
# Run locally
docker run -p 3000:3000 -e EXECUTOR_API_KEY=your-key tpmjs-executor`;
export default function RailwayExecutorPage(): React.ReactElement {
return (
<div className="min-h-screen bg-background flex flex-col">
<AppHeader />
<main className="flex-1">
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-foreground-secondary mb-8">
<Link href="/docs/executors" className="hover:text-foreground transition-colors">
Executors
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="text-foreground">Railway</span>
</nav>
{/* Header */}
<div className="mb-12">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-[#0B0D0E] rounded-lg flex items-center justify-center">
<svg
className="w-7 h-7"
viewBox="0 0 24 24"
fill="none"
role="img"
aria-labelledby="railway-header-logo"
>
<title id="railway-header-logo">Railway logo</title>
<path
d="M.113 12.611c-.139.312-.107.633.092.917.156.222.38.35.651.357h5.523a.28.28 0 00.216-.1c.07-.078.135-.22.2-.426.224-.718.428-1.09.65-1.492l.003-.005c.247-.448.51-.924.817-1.762.26-.712.296-1.371.113-2.008-.178-.62-.627-1.213-1.406-1.823a.248.248 0 00-.23-.044c-.078.025-.14.08-.178.157l-5.46 4.67a1.017 1.017 0 00-.287.41z"
fill="#fff"
/>
<path
d="M8.904 6.16a.258.258 0 00-.22.108c-.06.08-.08.178-.053.27.147.517.147 1.023 0 1.56-.114.416-.304.81-.49 1.194l-.04.083c-.214.443-.43.889-.595 1.42-.204.66-.21 1.336-.017 2.015.287 1.013.986 1.826 1.93 2.252l.014.006c.15.07.252.116.341.196.09.08.17.195.28.368l.023.035c.217.34.517.81.827 1.142a.244.244 0 00.204.091h1.694a.252.252 0 00.217-.12.234.234 0 00.006-.243 7.639 7.639 0 00-.66-1.016c-.253-.34-.523-.665-.76-.948a6.876 6.876 0 01-.507-.66c-.15-.228-.288-.53-.296-.857-.016-.679.413-1.39.85-1.994.168-.232.35-.484.52-.767.306-.51.383-1.054.227-1.62a2.568 2.568 0 00-.943-1.317 6.487 6.487 0 00-.98-.636 2.27 2.27 0 01-.516-.346.248.248 0 00-.214-.073l-.842.057z"
fill="#fff"
/>
<path
d="M20.756 6.225h-6.243a.255.255 0 00-.23.146.243.243 0 00.035.266c.32.362.618.742.892 1.137.317.46.597.946.833 1.45a.243.243 0 00.218.142h5.808c.278-.005.507-.133.667-.36.203-.287.234-.62.091-.94l-.765-1.563a.264.264 0 00-.24-.152l-1.066-.126z"
fill="#fff"
/>
<path
d="M15.82 11.152a.247.247 0 00-.229.158c-.243.648-.36.972-.618 1.46-.257.49-.527.84-1.058 1.468a.244.244 0 00.006.325c.235.264.496.505.78.722.425.327.883.608 1.365.837a.237.237 0 00.108.026h4.16a.255.255 0 00.229-.146.243.243 0 00-.035-.267c-.444-.502-.8-.98-1.172-1.632l-.006-.01c-.367-.642-.57-1.092-.83-1.872a.243.243 0 00-.232-.169l-2.468-.9z"
fill="#fff"
/>
<path
d="M13.873 16.68a.25.25 0 00-.2.074 4.588 4.588 0 01-.637.52c-.456.314-.797.472-1.236.647a.24.24 0 00-.147.137.249.249 0 00.004.2l.463.95c.098.2.285.32.5.317h6.357c.273-.013.497-.144.653-.374.198-.29.223-.622.074-.935l-.553-1.131a.257.257 0 00-.236-.152l-5.042-.253z"
fill="#fff"
/>
</svg>
</div>
<div>
<h1 className="text-3xl font-bold text-foreground">Deploy to Railway</h1>
<p className="text-foreground-secondary">
Always-on execution with auto-scaling and a free tier
</p>
</div>
</div>
</div>
{/* Why Railway */}
<section className="mb-12">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">$5</div>
<div className="text-sm text-foreground-secondary">Free monthly credit</div>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">0ms</div>
<div className="text-sm text-foreground-secondary">No cold starts</div>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">Auto</div>
<div className="text-sm text-foreground-secondary">Scaling built-in</div>
</div>
</div>
</section>
{/* One-Click Deploy */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">One-Click Deploy</h2>
<p className="text-foreground-secondary mb-6">
Deploy the TPMJS executor to Railway with a single click:
</p>
<a
href="https://railway.app/template/tpmjs-executor?referralCode=tpmjs"
target="_blank"
rel="noopener noreferrer"
>
<Button size="lg">
<svg
className="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M.113 12.611c-.139.312-.107.633.092.917.156.222.38.35.651.357h5.523a.28.28 0 00.216-.1c.07-.078.135-.22.2-.426.224-.718.428-1.09.65-1.492l.003-.005c.247-.448.51-.924.817-1.762.26-.712.296-1.371.113-2.008-.178-.62-.627-1.213-1.406-1.823a.248.248 0 00-.23-.044c-.078.025-.14.08-.178.157l-5.46 4.67a1.017 1.017 0 00-.287.41z" />
</svg>
Deploy on Railway
</Button>
</a>
<p className="text-sm text-foreground-tertiary mt-4">
After deployment, your executor will be available at{' '}
<code className="px-1.5 py-0.5 bg-surface rounded">
https://your-project.up.railway.app
</code>
</p>
</section>
{/* CLI Deploy */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Deploy via CLI</h2>
<p className="text-foreground-secondary mb-4">
Prefer the command line? Deploy with the Railway CLI:
</p>
<CodeBlock language="bash" code={cliDeploy} />
</section>
{/* Test Your Deployment */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Test Your Deployment</h2>
<p className="text-foreground-secondary mb-4">Verify your executor is running:</p>
<CodeBlock language="bash" code={healthCheck} />
<p className="text-sm text-foreground-secondary mt-4 mb-2">Expected response:</p>
<CodeBlock language="json" code={healthResponse} />
</section>
{/* Authentication */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Add Authentication</h2>
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg mb-4">
<p className="text-sm text-warning">
<strong>Important:</strong> Without an API key, anyone can execute tools on your
executor. Always set{' '}
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> in production.
</p>
</div>
<ol className="text-foreground-secondary space-y-3">
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
1
</span>
<span>Go to your Railway project dashboard</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
2
</span>
<span>Click on your service, then go to &quot;Variables&quot;</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
3
</span>
<span>
Add <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> with a
secure random value
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
4
</span>
<span>Railway will automatically redeploy with the new variable</span>
</li>
</ol>
</section>
{/* Environment Variables */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Environment Variables</h2>
<p className="text-foreground-secondary mb-4">
Add environment variables via the Railway dashboard or CLI:
</p>
<CodeBlock language="bash" code={envVars} />
<p className="text-sm text-foreground-tertiary mt-4">
These variables will be available during tool execution.
</p>
</section>
{/* Execute a Tool */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Execute a Tool</h2>
<p className="text-foreground-secondary mb-4">
Test tool execution with a curl request:
</p>
<CodeBlock language="bash" code={executeExample} />
</section>
{/* Local Development */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Local Development</h2>
<p className="text-foreground-secondary mb-4">Run the executor locally for testing:</p>
<CodeBlock language="bash" code={localDev} />
</section>
{/* Docker */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Docker Deployment</h2>
<p className="text-foreground-secondary mb-4">
The template includes a Dockerfile for container deployments:
</p>
<CodeBlock language="bash" code={dockerDeploy} />
<p className="text-sm text-foreground-tertiary mt-4">
Railway will automatically detect and use the Dockerfile if present.
</p>
</section>
{/* How It Works */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">How It Works</h2>
<p className="text-foreground-secondary mb-4">
The Railway executor runs as an always-on Node.js service:
</p>
<ol className="text-foreground-secondary space-y-3">
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
1
</span>
<span>
Receives tool execution request via HTTP POST to{' '}
<code className="px-1 bg-surface rounded">/execute-tool</code>
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
2
</span>
<span>Creates an isolated temporary directory for the execution</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
3
</span>
<span>
Installs the npm package using{' '}
<code className="px-1 bg-surface rounded">npm install</code>
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
4
</span>
<span>
Loads the tool and calls its{' '}
<code className="px-1 bg-surface rounded">execute()</code> function
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
5
</span>
<span>Returns the result and cleans up the temporary directory</span>
</li>
</ol>
</section>
{/* Security */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Security</h2>
<ul className="text-foreground-secondary space-y-2">
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>
Set <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> to require
authentication
</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Each tool execution uses an isolated temporary directory</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Environment variables stored encrypted by Railway</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>All traffic encrypted via HTTPS</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Auto-restart on failure for high availability</span>
</li>
</ul>
</section>
{/* Pricing */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Pricing</h2>
<p className="text-foreground-secondary mb-4">
Railway offers usage-based pricing with a generous free tier:
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-3 pr-4 font-medium text-foreground">Tier</th>
<th className="text-left py-3 px-4 font-medium text-foreground">Price</th>
<th className="text-left py-3 pl-4 font-medium text-foreground">Includes</th>
</tr>
</thead>
<tbody className="text-foreground-secondary">
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Free Tier</td>
<td className="py-3 px-4">$0/month</td>
<td className="py-3 pl-4">$5 credit, enough for light usage</td>
</tr>
<tr>
<td className="py-3 pr-4">Usage-based</td>
<td className="py-3 px-4">~$0.000463/min</td>
<td className="py-3 pl-4">0.5 vCPU, 512MB RAM</td>
</tr>
</tbody>
</table>
</div>
<p className="text-sm text-foreground-tertiary mt-4">
See{' '}
<a
href="https://railway.app/pricing"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Railway Pricing
</a>{' '}
for current rates.
</p>
</section>
{/* Connect to TPMJS */}
<section className="mb-12 p-6 bg-primary/5 border border-primary/20 rounded-lg">
<h2 className="text-lg font-semibold text-foreground mb-4">Connect to TPMJS</h2>
<ol className="text-foreground-secondary space-y-2">
<li>1. Go to your collection or agent settings on TPMJS</li>
<li>2. Select &quot;Custom Executor&quot; in Executor Configuration</li>
<li>
3. Enter URL:{' '}
<code className="px-1.5 py-0.5 bg-surface rounded">
https://your-project.up.railway.app
</code>
</li>
<li>4. Enter your API key (if configured)</li>
<li>5. Click &quot;Verify Connection&quot;</li>
</ol>
</section>
{/* Navigation */}
<div className="flex items-center justify-between pt-8 border-t border-border">
<Link
href="/docs/executors"
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<Icon icon="chevronLeft" className="w-4 h-4" />
<span>Back to Executors</span>
</Link>
<Link
href="/docs/executors/unsandbox"
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<span>Unsandbox Guide</span>
<Icon icon="chevronRight" className="w-4 h-4" />
</Link>
</div>
</div>
</main>
<AppFooter />
</div>
);
}

View file

@ -0,0 +1,420 @@
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import type { Metadata } from 'next';
import Link from 'next/link';
import { AppFooter } from '~/components/AppFooter';
import { AppHeader } from '~/components/AppHeader';
export const metadata: Metadata = {
title: 'Deploy to Unsandbox - Custom Executors - TPMJS',
description:
'Deploy a TPMJS executor to Unsandbox with one CLI command. Always-on, no cold starts, unlimited runtime.',
};
const deployCommand = `# Install the Unsandbox CLI
curl -fsSL https://unsandbox.com/install.sh | bash
# Deploy the TPMJS executor
un service --name tpmjs-executor --ports 80 -n semitrusted \\
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
const deployWithApiKey = `un service --name tpmjs-executor --ports 80 -n semitrusted \\
-e EXECUTOR_API_KEY=your-secure-random-key \\
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
const deployWithEnvVars = `un service --name tpmjs-executor --ports 80 -n semitrusted \\
-e EXECUTOR_API_KEY=your-key \\
-e OPENAI_API_KEY=sk-xxx \\
-e DATABASE_URL=postgres://... \\
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
const deployWithEnvFile = `# Create .env file with your secrets
cat > .env << EOF
EXECUTOR_API_KEY=your-key
OPENAI_API_KEY=sk-xxx
DATABASE_URL=postgres://...
EOF
# Deploy with env file
un service --name tpmjs-executor --ports 80 -n semitrusted \\
--env-file .env \\
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
const healthCheck = `curl https://tpmjs-executor.on.unsandbox.com/api/health`;
const healthResponse = `{
"status": "ok",
"version": "1.0.0",
"info": {
"runtime": "unsandbox",
"timestamp": "2024-01-01T00:00:00.000Z"
}
}`;
const executeExample = `curl -X POST https://tpmjs-executor.on.unsandbox.com/api/execute-tool \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer your-api-key" \\
-d '{
"packageName": "@tpmjs/hello",
"name": "helloWorldTool",
"version": "latest",
"params": { "includeTimestamp": true }
}'`;
const localDev = `# Clone the repository
git clone https://github.com/tpmjs/tpmjs.git
cd tpmjs/templates/unsandbox-executor
# Run locally
PORT=3000 node executor.js
# Test health endpoint
curl http://localhost:3000/api/health`;
const managementCommands = `# View logs
un service --logs tpmjs-executor
# Redeploy (after updating)
un service --redeploy tpmjs-executor
# Freeze when not in use (save costs)
un service --freeze tpmjs-executor
# Unfreeze when needed
un service --unfreeze tpmjs-executor
# Scale resources (4 vCPU, 8GB RAM)
un service --resize tpmjs-executor --vcpu 4
# Destroy service
un service --destroy tpmjs-executor`;
const customDomain = `un service --name tpmjs-executor --ports 80 -n semitrusted \\
--domains executor.yourdomain.com \\
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
export default function UnsandboxExecutorPage(): React.ReactElement {
return (
<div className="min-h-screen bg-background flex flex-col">
<AppHeader />
<main className="flex-1">
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-foreground-secondary mb-8">
<Link href="/docs/executors" className="hover:text-foreground transition-colors">
Executors
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="text-foreground">Unsandbox</span>
</nav>
{/* Header */}
<div className="mb-12">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-lg flex items-center justify-center text-white font-bold text-lg">
un
</div>
<div>
<h1 className="text-3xl font-bold text-foreground">Deploy to Unsandbox</h1>
<p className="text-foreground-secondary">
Always-on execution with one CLI command
</p>
</div>
</div>
</div>
{/* Why Unsandbox */}
<section className="mb-12">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">0ms</div>
<div className="text-sm text-foreground-secondary">No cold starts</div>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">&infin;</div>
<div className="text-sm text-foreground-secondary">Unlimited runtime</div>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">1 cmd</div>
<div className="text-sm text-foreground-secondary">Deploy in seconds</div>
</div>
</div>
</section>
{/* Quick Deploy */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Quick Deploy</h2>
<p className="text-foreground-secondary mb-4">
Deploy a TPMJS executor with a single command. Your executor will be live at{' '}
<code className="px-1.5 py-0.5 bg-surface rounded">
https://tpmjs-executor.on.unsandbox.com
</code>
</p>
<CodeBlock language="bash" code={deployCommand} />
<p className="text-sm text-foreground-tertiary mt-4">
This creates an always-on service that runs the executor. HTTPS is automatically
configured.
</p>
</section>
{/* Test Your Deployment */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Test Your Deployment</h2>
<p className="text-foreground-secondary mb-4">
Verify your executor is running with a health check:
</p>
<CodeBlock language="bash" code={healthCheck} />
<p className="text-sm text-foreground-secondary mt-4 mb-2">Expected response:</p>
<CodeBlock language="json" code={healthResponse} />
</section>
{/* Authentication */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Add Authentication</h2>
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg mb-4">
<p className="text-sm text-warning">
<strong>Important:</strong> Without an API key, anyone can execute tools on your
executor. Always set{' '}
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> in production.
</p>
</div>
<p className="text-foreground-secondary mb-4">
Deploy with an API key to require authentication:
</p>
<CodeBlock language="bash" code={deployWithApiKey} />
<p className="text-sm text-foreground-tertiary mt-4">
When configured, requests must include{' '}
<code className="px-1 bg-surface rounded">Authorization: Bearer your-api-key</code>.
</p>
</section>
{/* Environment Variables */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Environment Variables</h2>
<p className="text-foreground-secondary mb-4">
Pass environment variables that your tools need. These are available during tool
execution.
</p>
<h3 className="text-lg font-medium text-foreground mb-3">Inline Variables</h3>
<CodeBlock language="bash" code={deployWithEnvVars} />
<h3 className="text-lg font-medium text-foreground mt-6 mb-3">Using an Env File</h3>
<CodeBlock language="bash" code={deployWithEnvFile} />
<div className="p-4 bg-surface border border-border rounded-lg mt-4">
<p className="text-sm text-foreground-secondary">
All environment variables are stored encrypted and only available to your executor.
</p>
</div>
</section>
{/* Execute a Tool */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Execute a Tool</h2>
<p className="text-foreground-secondary mb-4">
Test tool execution with a curl request:
</p>
<CodeBlock language="bash" code={executeExample} />
</section>
{/* Local Development */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Local Development</h2>
<p className="text-foreground-secondary mb-4">
Run the executor locally for testing and development:
</p>
<CodeBlock language="bash" code={localDev} />
</section>
{/* Management Commands */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Managing Your Service</h2>
<p className="text-foreground-secondary mb-4">
Unsandbox provides commands to manage your executor:
</p>
<CodeBlock language="bash" code={managementCommands} />
<h3 className="text-lg font-medium text-foreground mt-6 mb-3">Cost Optimization</h3>
<p className="text-foreground-secondary mb-4">
Freeze your executor when not in use to stop billing:
</p>
<ul className="text-foreground-secondary text-sm space-y-2">
<li>
<code className="px-1 bg-surface rounded">un service --freeze</code> stops the
service and billing
</li>
<li>
<code className="px-1 bg-surface rounded">un service --unfreeze</code> restarts it
when needed
</li>
<li> Configure auto-unfreeze to wake on HTTP request (incurs cold start)</li>
</ul>
</section>
{/* Custom Domains */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Custom Domains</h2>
<p className="text-foreground-secondary mb-4">
Use your own domain instead of the default{' '}
<code className="px-1 bg-surface rounded">*.on.unsandbox.com</code>:
</p>
<CodeBlock language="bash" code={customDomain} />
<p className="text-foreground-secondary text-sm mt-4">
After deploying, add a CNAME record pointing{' '}
<code className="px-1 bg-surface rounded">executor.yourdomain.com</code> to your
Unsandbox service domain.
</p>
</section>
{/* How It Works */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">How It Works</h2>
<p className="text-foreground-secondary mb-4">
The Unsandbox executor runs as an always-on HTTP server:
</p>
<ol className="text-foreground-secondary space-y-3">
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
1
</span>
<span>
Receives tool execution request via HTTP POST to{' '}
<code className="px-1 bg-surface rounded">/api/execute-tool</code>
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
2
</span>
<span>Creates an isolated temporary directory for the execution</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
3
</span>
<span>
Installs the npm package using{' '}
<code className="px-1 bg-surface rounded">npm install</code>
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
4
</span>
<span>
Loads the tool and calls its{' '}
<code className="px-1 bg-surface rounded">execute()</code> function
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
5
</span>
<span>Returns the result and cleans up the temporary directory</span>
</li>
</ol>
<p className="text-foreground-secondary text-sm mt-4">
Since Unsandbox containers are already isolated, no additional sandbox layer is
needed. Network access is controlled by Unsandbox&apos;s semitrusted mode.
</p>
</section>
{/* Security */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Security</h2>
<ul className="text-foreground-secondary space-y-2">
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>
Set <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> to require
authentication
</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Tools run in isolated Unsandbox containers</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Each execution uses a fresh temporary directory</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Network controlled by semitrusted mode</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Environment variables stored encrypted</span>
</li>
</ul>
</section>
{/* Pricing */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Pricing</h2>
<p className="text-foreground-secondary mb-4">
Unsandbox services are billed based on uptime. See{' '}
<a
href="https://unsandbox.com/pricing"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Unsandbox Pricing
</a>{' '}
for current rates.
</p>
<ul className="text-foreground-secondary text-sm space-y-2">
<li>
HTTPS included via{' '}
<code className="px-1 bg-surface rounded">*.on.unsandbox.com</code>
</li>
<li> Freeze when not in use to pause billing</li>
<li> Scale vCPU and RAM as needed</li>
</ul>
</section>
{/* Connect to TPMJS */}
<section className="mb-12 p-6 bg-primary/5 border border-primary/20 rounded-lg">
<h2 className="text-lg font-semibold text-foreground mb-4">Connect to TPMJS</h2>
<ol className="text-foreground-secondary space-y-2">
<li>1. Go to your collection or agent settings on TPMJS</li>
<li>2. Select &quot;Custom Executor&quot; in Executor Configuration</li>
<li>
3. Enter URL:{' '}
<code className="px-1.5 py-0.5 bg-surface rounded">
https://tpmjs-executor.on.unsandbox.com
</code>
</li>
<li>4. Enter your API key (if configured)</li>
<li>5. Click &quot;Verify Connection&quot;</li>
</ol>
</section>
{/* Navigation */}
<div className="flex items-center justify-between pt-8 border-t border-border">
<Link
href="/docs/executors/railway"
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<Icon icon="chevronLeft" className="w-4 h-4" />
<span>Railway Guide</span>
</Link>
<Link
href="/docs/executors/vercel"
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<span>Vercel Guide</span>
<Icon icon="chevronRight" className="w-4 h-4" />
</Link>
</div>
</div>
</main>
<AppFooter />
</div>
);
}

View file

@ -0,0 +1,426 @@
import { Button } from '@tpmjs/ui/Button/Button';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import type { Metadata } from 'next';
import Link from 'next/link';
import { AppFooter } from '~/components/AppFooter';
import { AppHeader } from '~/components/AppHeader';
export const metadata: Metadata = {
title: 'Deploy to Vercel - Custom Executors - TPMJS',
description:
'Deploy a TPMJS executor to Vercel with one click. VM-level isolation using Vercel Sandbox.',
};
const healthCheck = `curl https://your-executor.vercel.app/api/health`;
const healthResponse = `{
"status": "ok",
"version": "1.0.0",
"info": {
"runtime": "vercel-sandbox",
"region": "iad1",
"timestamp": "2024-01-01T00:00:00.000Z"
}
}`;
const executeExample = `curl -X POST https://your-executor.vercel.app/api/execute-tool \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer your-api-key" \\
-d '{
"packageName": "@tpmjs/hello",
"name": "helloWorld",
"version": "latest",
"params": { "name": "World" }
}'`;
const localDev = `# Clone and install
git clone https://github.com/tpmjs/tpmjs.git
cd tpmjs/templates/vercel-executor
npm install
# Login to Vercel (required for sandbox)
vercel login
vercel link
# Pull environment variables
vercel env pull
# Run development server
npm run dev
# Test health endpoint
curl http://localhost:3000/api/health`;
export default function VercelExecutorPage(): React.ReactElement {
return (
<div className="min-h-screen bg-background flex flex-col">
<AppHeader />
<main className="flex-1">
<div className="max-w-4xl mx-auto px-4 py-12">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-foreground-secondary mb-8">
<Link href="/docs/executors" className="hover:text-foreground transition-colors">
Executors
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="text-foreground">Vercel</span>
</nav>
{/* Header */}
<div className="mb-12">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
viewBox="0 0 76 65"
fill="currentColor"
aria-label="Vercel logo"
>
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
</svg>
</div>
<div>
<h1 className="text-3xl font-bold text-foreground">Deploy to Vercel</h1>
<p className="text-foreground-secondary">
One-click deploy with VM-level isolation
</p>
</div>
</div>
</div>
{/* Why Vercel */}
<section className="mb-12">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">1-click</div>
<div className="text-sm text-foreground-secondary">Deploy instantly</div>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">VM</div>
<div className="text-sm text-foreground-secondary">Sandbox isolation</div>
</div>
<div className="p-4 bg-surface border border-border rounded-lg">
<div className="text-2xl mb-2">Free</div>
<div className="text-sm text-foreground-secondary">Hobby tier available</div>
</div>
</div>
</section>
{/* One-Click Deploy */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">One-Click Deploy</h2>
<p className="text-foreground-secondary mb-6">
Deploy the TPMJS executor template to your Vercel account:
</p>
<a
href="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"
target="_blank"
rel="noopener noreferrer"
>
<Button size="lg">
<svg
className="w-4 h-4 mr-2"
viewBox="0 0 76 65"
fill="currentColor"
aria-hidden="true"
>
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
</svg>
Deploy with Vercel
</Button>
</a>
<p className="text-sm text-foreground-tertiary mt-4">
After deployment, your executor will be available at{' '}
<code className="px-1.5 py-0.5 bg-surface rounded">
https://tpmjs-executor.vercel.app
</code>
</p>
</section>
{/* Test Your Deployment */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Test Your Deployment</h2>
<p className="text-foreground-secondary mb-4">Verify your executor is running:</p>
<CodeBlock language="bash" code={healthCheck} />
<p className="text-sm text-foreground-secondary mt-4 mb-2">Expected response:</p>
<CodeBlock language="json" code={healthResponse} />
</section>
{/* Authentication */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Add Authentication</h2>
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg mb-4">
<p className="text-sm text-warning">
<strong>Important:</strong> Without an API key, anyone can execute tools on your
executor. Always set{' '}
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> in production.
</p>
</div>
<ol className="text-foreground-secondary space-y-3">
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
1
</span>
<span>Go to your Vercel project settings</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
2
</span>
<span>Navigate to Environment Variables</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
3
</span>
<span>
Add <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> with a
secure random value
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
4
</span>
<span>Redeploy your project to apply the changes</span>
</li>
</ol>
</section>
{/* Environment Variables */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Environment Variables</h2>
<p className="text-foreground-secondary mb-4">
Add custom environment variables for your tools in Vercel project settings:
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-3 pr-4 font-medium text-foreground">Variable</th>
<th className="text-left py-3 px-4 font-medium text-foreground">Required</th>
<th className="text-left py-3 pl-4 font-medium text-foreground">Description</th>
</tr>
</thead>
<tbody className="text-foreground-secondary">
<tr className="border-b border-border/50">
<td className="py-3 pr-4">
<code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code>
</td>
<td className="py-3 px-4">No*</td>
<td className="py-3 pl-4">
API key for authentication. Required for production.
</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 pr-4">
<code className="px-1 bg-surface rounded">OPENAI_API_KEY</code>
</td>
<td className="py-3 px-4">No</td>
<td className="py-3 pl-4">Example: Pass through to tools that need OpenAI</td>
</tr>
<tr>
<td className="py-3 pr-4">
<code className="px-1 bg-surface rounded">DATABASE_URL</code>
</td>
<td className="py-3 px-4">No</td>
<td className="py-3 pl-4">Example: Pass through to tools that need database</td>
</tr>
</tbody>
</table>
</div>
<p className="text-sm text-foreground-tertiary mt-4">
* Strongly recommended for production deployments
</p>
</section>
{/* Execute a Tool */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Execute a Tool</h2>
<p className="text-foreground-secondary mb-4">
Test tool execution with a curl request:
</p>
<CodeBlock language="bash" code={executeExample} />
</section>
{/* Local Development */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Local Development</h2>
<p className="text-foreground-secondary mb-4">
Run the executor locally for testing. Note: Vercel Sandbox requires authentication
even in development.
</p>
<CodeBlock language="bash" code={localDev} />
<div className="p-4 bg-surface border border-border rounded-lg mt-4">
<p className="text-sm text-foreground-secondary">
<strong>Note:</strong> You must run{' '}
<code className="px-1 bg-surface-secondary rounded">vercel login</code> and{' '}
<code className="px-1 bg-surface-secondary rounded">vercel link</code> before local
development. Vercel Sandbox requires authentication to create VMs.
</p>
</div>
</section>
{/* How It Works */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">How It Works</h2>
<p className="text-foreground-secondary mb-4">
The Vercel executor uses{' '}
<a
href="https://vercel.com/docs/vercel-sandbox"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Vercel Sandbox
</a>{' '}
for isolated execution:
</p>
<ol className="text-foreground-secondary space-y-3">
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
1
</span>
<span>Creates an isolated VM for each tool execution</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
2
</span>
<span>Installs the npm package in the sandbox</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
3
</span>
<span>Executes the tool with your parameters</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
4
</span>
<span>Returns the result and destroys the sandbox</span>
</li>
</ol>
<p className="text-foreground-secondary text-sm mt-4">
This provides VM-level isolation without the limitations of Node.js serverless
functions.
</p>
</section>
{/* Security */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Security</h2>
<ul className="text-foreground-secondary space-y-2">
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>
Set <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> to require
authentication
</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Tools run in isolated VMs with no access to your Vercel project</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Each execution gets a fresh sandbox instance</span>
</li>
<li className="flex items-start gap-2">
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
<span>Sandboxes are destroyed after execution completes</span>
</li>
</ul>
</section>
{/* Pricing & Limits */}
<section className="mb-12">
<h2 className="text-2xl font-semibold text-foreground mb-4">Pricing & Limits</h2>
<p className="text-foreground-secondary mb-4">
Vercel Sandbox usage is billed based on compute time. See{' '}
<a
href="https://vercel.com/docs/vercel-sandbox/pricing"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Vercel Sandbox Pricing
</a>{' '}
for current rates.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-3 pr-4 font-medium text-foreground">Plan</th>
<th className="text-left py-3 px-4 font-medium text-foreground">Max Runtime</th>
<th className="text-left py-3 pl-4 font-medium text-foreground">Notes</th>
</tr>
</thead>
<tbody className="text-foreground-secondary">
<tr className="border-b border-border/50">
<td className="py-3 pr-4">Hobby</td>
<td className="py-3 px-4">45 minutes</td>
<td className="py-3 pl-4">Free tier</td>
</tr>
<tr>
<td className="py-3 pr-4">Pro</td>
<td className="py-3 px-4">5 hours</td>
<td className="py-3 pl-4">For longer-running tools</td>
</tr>
</tbody>
</table>
</div>
<div className="p-4 bg-surface border border-border rounded-lg mt-4">
<p className="text-sm text-foreground-secondary">
<strong>Region:</strong> Vercel Sandbox is currently only available in{' '}
<code className="px-1 bg-surface-secondary rounded">iad1</code> (US East).
</p>
</div>
</section>
{/* Connect to TPMJS */}
<section className="mb-12 p-6 bg-primary/5 border border-primary/20 rounded-lg">
<h2 className="text-lg font-semibold text-foreground mb-4">Connect to TPMJS</h2>
<ol className="text-foreground-secondary space-y-2">
<li>1. Go to your collection or agent settings on TPMJS</li>
<li>2. Select &quot;Custom Executor&quot; in Executor Configuration</li>
<li>
3. Enter URL:{' '}
<code className="px-1.5 py-0.5 bg-surface rounded">
https://tpmjs-executor.vercel.app
</code>
</li>
<li>4. Enter your API key (if configured)</li>
<li>5. Click &quot;Verify Connection&quot;</li>
</ol>
</section>
{/* Navigation */}
<div className="flex items-center justify-between pt-8 border-t border-border">
<Link
href="/docs/executors/unsandbox"
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<Icon icon="chevronLeft" className="w-4 h-4" />
<span>Unsandbox Guide</span>
</Link>
<Link
href="/docs/executors"
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<span>Back to Executors</span>
<Icon icon="chevronRight" className="w-4 h-4" />
</Link>
</div>
</div>
</main>
<AppFooter />
</div>
);
}

View file

@ -1308,8 +1308,8 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`}
<CodeBlock
language="bash"
code={`claude mcp add tpmjs-my-collection \\
-- npx mcp-remote https://tpmjs.com/api/mcp/<username>/<collection-slug>/http \\
--header "Authorization: Bearer YOUR_TPMJS_API_KEY"`}
https://tpmjs.com/api/mcp/<username>/<collection-slug>/http \\
-t http -H "Authorization: Bearer YOUR_TPMJS_API_KEY"`}
/>
<p className="text-foreground-secondary mt-4">
This automatically adds the server to your Claude Code configuration.

View file

@ -802,8 +802,8 @@ Invalid usernames:
<CodeBlock
language="bash"
code={`claude mcp add tpmjs-my-collection \\
-- npx mcp-remote https://tpmjs.com/api/mcp/YOUR_USERNAME/YOUR_COLLECTION_SLUG/http \\
--header "Authorization: Bearer YOUR_TPMJS_API_KEY"`}
https://tpmjs.com/api/mcp/YOUR_USERNAME/YOUR_COLLECTION_SLUG/http \\
-t http -H "Authorization: Bearer YOUR_TPMJS_API_KEY"`}
/>
</DocSubSection>
</DocSection>

View file

@ -5,6 +5,7 @@ import { Container } from '@tpmjs/ui/Container/Container';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { AppHeader } from '../components/AppHeader';
import { EcosystemStats } from '../components/home/EcosystemStats';
import { FeaturesSection } from '../components/home/FeaturesSection';
import { HeroSection } from '../components/home/HeroSection';
@ -13,95 +14,127 @@ export const dynamic = 'force-dynamic';
async function getHomePageData() {
try {
// Fetch stats in parallel
const [packageCount, toolCount, featuredTools, categoryStats, featuredScenarios] =
await Promise.all([
// Total package count
prisma.package.count(),
const [
packageCount,
toolCount,
featuredTools,
categoryStats,
featuredScenarios,
latestSnapshot,
] = await Promise.all([
// Total package count
prisma.package.count(),
// Total tool count
prisma.tool.count(),
// Total tool count
prisma.tool.count(),
// Top 6 featured tools by quality score
prisma.tool.findMany({
orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }],
take: 6,
select: {
id: true,
name: true,
description: true,
qualityScore: true,
package: {
// Top 6 featured tools by quality score
prisma.tool.findMany({
orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }],
take: 6,
select: {
id: true,
name: true,
description: true,
qualityScore: true,
likeCount: true,
package: {
select: {
npmPackageName: true,
category: true,
npmDownloadsLastMonth: true,
isOfficial: true,
},
},
},
}),
// Category distribution for stats (group by package category)
prisma.package.groupBy({
by: ['category'],
_count: {
_all: true,
},
}),
// Featured scenarios - mix of high quality, diverse, and fresh
(async () => {
// Get high quality scenarios
const highQuality = await prisma.scenario.findMany({
where: {
collection: { isPublic: true },
qualityScore: { gte: 0.3 },
totalRuns: { gte: 1 },
},
orderBy: { qualityScore: 'desc' },
take: 3,
include: {
collection: {
select: {
npmPackageName: true,
category: true,
npmDownloadsLastMonth: true,
isOfficial: true,
id: true,
name: true,
slug: true,
user: { select: { username: true } },
},
},
},
}),
});
// Category distribution for stats (group by package category)
prisma.package.groupBy({
by: ['category'],
_count: {
_all: true,
// Get fresh scenarios (excluding already selected)
const seenIds = new Set(highQuality.map((s) => s.id));
const fresh = await prisma.scenario.findMany({
where: {
collection: { isPublic: true },
id: { notIn: Array.from(seenIds) },
},
}),
// Featured scenarios - mix of high quality, diverse, and fresh
(async () => {
// Get high quality scenarios
const highQuality = await prisma.scenario.findMany({
where: {
collection: { isPublic: true },
qualityScore: { gte: 0.3 },
totalRuns: { gte: 1 },
},
orderBy: { qualityScore: 'desc' },
take: 3,
include: {
collection: {
select: {
id: true,
name: true,
slug: true,
user: { select: { username: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
include: {
collection: {
select: {
id: true,
name: true,
slug: true,
user: { select: { username: true } },
},
},
});
},
});
// Get fresh scenarios (excluding already selected)
const seenIds = new Set(highQuality.map((s) => s.id));
const fresh = await prisma.scenario.findMany({
where: {
collection: { isPublic: true },
id: { notIn: Array.from(seenIds) },
},
orderBy: { createdAt: 'desc' },
take: 3,
include: {
collection: {
select: {
id: true,
name: true,
slug: true,
user: { select: { username: true } },
},
},
},
});
return [...highQuality, ...fresh].slice(0, 6);
})(),
return [...highQuality, ...fresh].slice(0, 6);
})(),
]);
// Latest stats snapshot (pre-computed daily)
prisma.statsSnapshot.findFirst({
orderBy: { date: 'desc' },
select: {
totalTools: true,
totalPackages: true,
totalNpmDownloads: true,
totalGithubStars: true,
executionsTotal: true,
executionsAvgTimeMs: true,
activeDevs7d: true,
totalSimulations: true,
categories: true,
},
}),
]);
return {
stats: {
packageCount,
toolCount,
categoryCount: categoryStats.length,
totalDownloads: latestSnapshot?.totalNpmDownloads ?? 0,
totalStars: latestSnapshot?.totalGithubStars ?? 0,
},
ecosystemStats: {
publishedTools: latestSnapshot?.totalTools ?? toolCount,
activeDevelopers: latestSnapshot?.activeDevs7d ?? 0,
totalExecutions: latestSnapshot?.totalSimulations ?? 0,
avgResponseMs: latestSnapshot?.executionsAvgTimeMs ?? null,
totalDownloads: latestSnapshot?.totalNpmDownloads ?? 0,
},
featuredTools,
categories: categoryStats.slice(0, 5).map((c) => ({
@ -117,6 +150,15 @@ async function getHomePageData() {
packageCount: 0,
toolCount: 0,
categoryCount: 0,
totalDownloads: 0,
totalStars: 0,
},
ecosystemStats: {
publishedTools: 0,
activeDevelopers: 0,
totalExecutions: 0,
avgResponseMs: null,
totalDownloads: 0,
},
featuredTools: [],
categories: [],
@ -135,8 +177,11 @@ export default async function HomePage(): Promise<React.ReactElement> {
{/* Hero Section - Dithered Design */}
<HeroSection stats={data.stats} />
{/* Ecosystem Stats */}
<EcosystemStats stats={data.ecosystemStats} />
{/* Features Section */}
<FeaturesSection />
<FeaturesSection toolCount={data.stats.toolCount} />
{/* Architecture Diagram Section - temporarily disabled
<section className="py-16 bg-background border-b border-border">
@ -200,14 +245,20 @@ export default async function HomePage(): Promise<React.ReactElement> {
</div>
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
{tool.qualityScore && Number(tool.qualityScore) > 0 ? (
<span className="flex items-center gap-1">
<span className="text-brutalist-accent"></span>
{Number(tool.qualityScore).toFixed(2)}
</span>
) : (
<span />
)}
<div className="flex items-center gap-3">
{tool.qualityScore && Number(tool.qualityScore) > 0 ? (
<span className="flex items-center gap-1">
<span className="text-brutalist-accent"></span>
{Number(tool.qualityScore).toFixed(2)}
</span>
) : null}
{tool.likeCount > 0 && (
<span className="flex items-center gap-1">
<Icon icon="heart" className="w-3 h-3 text-error" />
{tool.likeCount}
</span>
)}
</div>
<span>
{(tool.package.npmDownloadsLastMonth ?? 0) > 0
? `${tool.package.npmDownloadsLastMonth?.toLocaleString()} downloads/mo`
@ -459,7 +510,9 @@ export default async function HomePage(): Promise<React.ReactElement> {
<div className="w-2 h-2 bg-success rounded-full animate-pulse" />
<p className="font-mono text-xs text-foreground-secondary">
add to config instant access to{' '}
<span className="text-primary font-medium">170+ tools</span>
<span className="text-primary font-medium">
{data.stats.toolCount > 0 ? `${data.stats.toolCount}+` : '100+'} tools
</span>
</p>
</div>
</fieldset>

View file

@ -0,0 +1,65 @@
import type { Metadata } from 'next';
import { AppHeader } from '~/components/AppHeader';
import { TechDiagram } from '~/components/tech/TechDiagram';
export const metadata: Metadata = {
title: 'Tech Stack | TPMJS',
description:
'Interactive isometric diagram of the TPMJS ecosystem architecture. Explore external services, applications, published packages, internal packages, and official tools.',
};
export default function TechPage(): React.ReactElement {
return (
<div className="min-h-screen flex flex-col bg-background">
<AppHeader />
<main className="flex-1">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
{/* Header */}
<div className="text-center mb-8 sm:mb-12">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4 text-foreground">
TPMJS Tech Stack
</h1>
<p className="text-lg text-foreground-secondary max-w-2xl mx-auto">
Interactive isometric view of the entire TPMJS ecosystem. Pan and zoom to explore how
services, apps, and packages connect.
</p>
</div>
{/* Diagram */}
<TechDiagram />
{/* Legend */}
<div className="mt-8 sm:mt-12 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{[
{
label: 'External Services',
color: 'bg-orange-100 dark:bg-orange-900/30 border-orange-500',
},
{
label: 'Applications',
color: 'bg-blue-100 dark:bg-blue-900/30 border-blue-500',
},
{
label: 'Published Packages',
color: 'bg-green-100 dark:bg-green-900/30 border-green-500',
},
{
label: 'Internal Packages',
color: 'bg-purple-100 dark:bg-purple-900/30 border-purple-500',
},
{
label: 'Official Tools',
color: 'bg-pink-100 dark:bg-pink-900/30 border-pink-500',
},
].map((item) => (
<div key={item.label} className="flex items-center gap-2">
<div className={`w-4 h-4 rounded border-2 ${item.color}`} />
<span className="text-sm text-foreground-secondary">{item.label}</span>
</div>
))}
</div>
</div>
</main>
</div>
);
}

View file

@ -17,6 +17,7 @@ import { LikeButton } from '~/components/LikeButton';
import { Markdown } from '~/components/Markdown';
import { Rating } from '~/components/Rating';
import { ToolPlayground } from '~/components/ToolPlayground';
import { useTrackView } from '~/hooks/useTrackView';
interface Package {
id: string;
@ -70,6 +71,7 @@ export interface Tool {
healthCheckError?: string | null;
lastHealthCheck?: string | null;
likeCount?: number;
viewCount?: number;
averageRating?: string | null;
ratingCount?: number;
reviewCount?: number;
@ -88,6 +90,9 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R
const [recheckLoading, setRecheckLoading] = useState(false);
const [extractSchemaLoading, setExtractSchemaLoading] = useState(false);
// Track page view
useTrackView('tool', tool.id);
const pkg = tool.package;
const authorName = typeof pkg.npmAuthor === 'string' ? pkg.npmAuthor : pkg.npmAuthor?.name;
@ -194,6 +199,7 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R
<div className="min-h-screen bg-background">
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for structured data ld+json
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationSchema) }}
/>
<AppHeader />
@ -600,6 +606,22 @@ console.log(result.text);`}
</p>
</div>
)}
{(tool.viewCount ?? 0) > 0 && (
<div>
<p className="text-sm text-foreground-secondary mb-1">Views</p>
<p className="text-2xl font-bold text-foreground">
{tool.viewCount?.toLocaleString()}
</p>
</div>
)}
{(tool.reviewCount ?? 0) > 0 && (
<div>
<p className="text-sm text-foreground-secondary mb-1">Reviews</p>
<p className="text-2xl font-bold text-foreground">
{tool.reviewCount?.toLocaleString()}
</p>
</div>
)}
<div>
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
<ProgressBar

View file

@ -79,6 +79,7 @@ async function getTool(slug: string[]): Promise<Tool | null> {
healthCheckError: tool.healthCheckError ?? null,
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
likeCount: tool.likeCount,
viewCount: tool.viewCount,
averageRating: tool.averageRating?.toString() ?? null,
ratingCount: tool.ratingCount,
reviewCount: tool.reviewCount,

View file

@ -0,0 +1,304 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useState } from 'react';
import { ForkButton } from '~/components/ForkButton';
interface EnvVar {
name: string;
description?: string;
}
interface InstallationSectionProps {
collection: {
id: string;
slug: string;
name: string;
toolCount: number;
envVars?: Record<string, string> | null;
};
username: string;
isPrivate: boolean;
/** Whether to show the fork button (typically false for dashboard/owner view) */
showForkButton?: boolean;
}
export function InstallationSection({
collection,
username,
isPrivate,
showForkButton = true,
}: InstallationSectionProps) {
const [copiedCommand, setCopiedCommand] = useState(false);
const [showClaudeDesktop, setShowClaudeDesktop] = useState(false);
const [showTroubleshooting, setShowTroubleshooting] = useState(false);
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
const mcpUrl = `${baseUrl}/@${username}/collections/${collection.slug}/mcp`;
// Build the claude mcp add command
// Positional args (name, url) must come before flags to avoid -H swallowing them
const commandParts = ['claude mcp add'];
commandParts.push(collection.slug);
commandParts.push(mcpUrl);
commandParts.push('-t http');
if (isPrivate) {
commandParts.push('-H "Authorization: Bearer YOUR_TPMJS_API_KEY"');
}
const installCommand = commandParts.join(' \\\n ');
// Build Claude Desktop config JSON
const claudeDesktopConfig = isPrivate
? JSON.stringify(
{
mcpServers: {
[collection.slug]: {
type: 'http',
url: mcpUrl,
headers: {
Authorization: 'Bearer YOUR_TPMJS_API_KEY',
},
},
},
},
null,
2
)
: JSON.stringify(
{
mcpServers: {
[collection.slug]: {
type: 'http',
url: mcpUrl,
},
},
},
null,
2
);
// Extract env var names from collection
const envVarsList: EnvVar[] = collection.envVars
? Object.keys(collection.envVars).map((name) => ({ name }))
: [];
const copyCommand = async () => {
// Copy the flat command (without line breaks for easy pasting)
const flatCommand = isPrivate
? `claude mcp add ${collection.slug} ${mcpUrl} -t http -H "Authorization: Bearer YOUR_TPMJS_API_KEY"`
: `claude mcp add ${collection.slug} ${mcpUrl} -t http`;
await navigator.clipboard.writeText(flatCommand);
setCopiedCommand(true);
setTimeout(() => setCopiedCommand(false), 2000);
};
return (
<section className="bg-surface border border-border rounded-lg p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-foreground">Installation</h2>
<Badge variant="secondary" size="sm">
{collection.toolCount} {collection.toolCount === 1 ? 'tool' : 'tools'}
</Badge>
</div>
</div>
{/* Env Vars Warning */}
{envVarsList.length > 0 && (
<div className="mb-6 p-4 bg-warning/10 border border-warning/30 rounded-lg">
<div className="flex items-start gap-3">
<Icon icon="alertTriangle" size="sm" className="text-warning mt-0.5 shrink-0" />
<div>
<h3 className="font-medium text-foreground">Required Environment Variables</h3>
<p className="text-sm text-foreground-secondary mt-1">
Set these before tools will work:
</p>
<ul className="mt-2 space-y-1">
{envVarsList.map((envVar) => (
<li key={envVar.name} className="text-sm">
<code className="font-mono text-xs bg-surface px-1.5 py-0.5 rounded border border-border">
{envVar.name}
</code>
{envVar.description && (
<span className="text-foreground-tertiary ml-2"> {envVar.description}</span>
)}
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Step 1: Add to Claude */}
<div className="mb-6">
<h3 className="text-sm font-medium text-foreground mb-2">Step 1: Add to Claude</h3>
<div className="relative">
<pre className="p-4 bg-surface-secondary border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all">
{installCommand}
</pre>
<Button
variant="ghost"
size="sm"
onClick={copyCommand}
className="absolute top-2 right-2"
>
<Icon icon={copiedCommand ? 'check' : 'copy'} size="xs" />
</Button>
</div>
{isPrivate && (
<p className="mt-2 text-xs text-foreground-tertiary">
Get your API key from{' '}
<Link
href="/dashboard/settings/tpmjs-api-keys"
className="text-primary hover:underline"
>
Settings
</Link>
</p>
)}
</div>
{/* Step 2: Verify */}
<div className="mb-6">
<h3 className="text-sm font-medium text-foreground mb-2">Step 2: Verify</h3>
<p className="text-sm text-foreground-secondary">
Run{' '}
<code className="font-mono text-xs bg-surface-secondary px-1.5 py-0.5 rounded border border-border">
/mcp
</code>{' '}
in Claude Code to confirm the server is connected.
</p>
</div>
{/* Fork Button */}
{showForkButton && (
<div className="mb-6">
<ForkButton
type="collection"
sourceId={collection.id}
sourceName={collection.name}
variant="full"
className="w-full"
/>
</div>
)}
{/* Collapsible Sections */}
<div className="pt-4 border-t border-border space-y-2">
{/* Claude Desktop */}
<button
type="button"
onClick={() => setShowClaudeDesktop(!showClaudeDesktop)}
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
>
<Icon icon={showClaudeDesktop ? 'chevronDown' : 'chevronRight'} size="xs" />
<span>Using Claude Desktop instead?</span>
</button>
{showClaudeDesktop && (
<div className="mt-3 ml-6">
<p className="text-sm text-foreground-secondary mb-2">
Add this to your{' '}
<code className="font-mono text-xs bg-surface-secondary px-1.5 py-0.5 rounded border border-border">
claude_desktop_config.json
</code>{' '}
file:
</p>
<CodeBlock language="json" code={claudeDesktopConfig} />
{isPrivate && (
<p className="mt-2 text-xs text-foreground-tertiary">
Replace <code className="font-mono">YOUR_TPMJS_API_KEY</code> with your{' '}
<Link
href="/dashboard/settings/tpmjs-api-keys"
className="text-primary hover:underline"
>
TPMJS API key
</Link>
</p>
)}
</div>
)}
{/* Troubleshooting */}
<button
type="button"
onClick={() => setShowTroubleshooting(!showTroubleshooting)}
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
>
<Icon icon={showTroubleshooting ? 'chevronDown' : 'chevronRight'} size="xs" />
<span>Troubleshooting</span>
</button>
{showTroubleshooting && (
<div className="mt-3 ml-6 space-y-4 text-sm">
{/* Connection timeout */}
<div>
<h4 className="font-medium text-foreground">Connection timeout</h4>
<ul className="mt-1 text-foreground-secondary space-y-1">
<li>
Try increasing the timeout:{' '}
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
MCP_TIMEOUT=10000 claude
</code>
</li>
<li> Check firewall/VPN settings</li>
</ul>
</div>
{/* Auth failures */}
<div>
<h4 className="font-medium text-foreground">Authentication failures</h4>
<ul className="mt-1 text-foreground-secondary space-y-1">
<li> Verify your API key is correct</li>
<li>
Ensure the header format is{' '}
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
Authorization: Bearer YOUR_KEY
</code>
</li>
<li> Check that your API key hasn&apos;t expired</li>
</ul>
</div>
{/* Server not appearing */}
<div>
<h4 className="font-medium text-foreground">Server not appearing</h4>
<ul className="mt-1 text-foreground-secondary space-y-1">
<li>
Run{' '}
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
claude mcp list
</code>{' '}
to see configured servers
</li>
<li>
Try removing and re-adding:{' '}
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
claude mcp remove {collection.slug}
</code>
</li>
</ul>
</div>
{/* Tools not loading */}
<div>
<h4 className="font-medium text-foreground">Tools not loading</h4>
<ul className="mt-1 text-foreground-secondary space-y-1">
<li> The server may take a moment to initialize on first connection</li>
<li> Check that required environment variables are set</li>
</ul>
</div>
</div>
)}
</div>
</section>
);
}

View file

@ -1,18 +1,48 @@
/**
* EcosystemStats Component
*
* Redesigned statistics section with dithered numbers and live activity stream.
*/
'use client';
import { ActivityStream } from '@tpmjs/ui/ActivityStream/ActivityStream';
import { Container } from '@tpmjs/ui/Container/Container';
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
import { statistics } from '../../data/homePageData';
import { PublicActivityStream } from './PublicActivityStream';
interface EcosystemStatsProps {
stats: {
publishedTools: number;
activeDevelopers: number;
totalExecutions: number;
avgResponseMs: number | null;
totalDownloads: number;
};
}
export function EcosystemStats({ stats }: EcosystemStatsProps): React.ReactElement {
const statistics = [
{
value: stats.publishedTools,
label: 'Published Tools',
subtext: 'Auto-synced from npm',
suffix: '',
},
{
value: stats.activeDevelopers,
label: 'Active Developers',
subtext: 'Last 7 days',
suffix: '',
},
{
value: stats.totalExecutions,
label: 'Total Executions',
subtext: 'All-time simulations',
suffix: '',
},
{
value: stats.avgResponseMs ?? 0,
label: 'Avg Response',
subtext: 'Execution latency',
suffix: 'ms',
},
];
export function EcosystemStats(): React.ReactElement {
return (
<section className="py-16 md:py-24 bg-surface relative overflow-hidden">
{/* Subtle grid background */}
@ -23,34 +53,28 @@ export function EcosystemStats(): React.ReactElement {
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{statistics.map((stat, index) => {
// Extract number from value string
const numValue = Number.parseInt(stat.value.replace(/[^0-9]/g, ''), 10) || 0;
const suffix = stat.value.replace(/[0-9,]/g, '');
return (
<div
key={stat.label}
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
>
<StatCard
value={numValue}
label={stat.label}
subtext={stat.subtext}
suffix={suffix}
variant="brutalist"
size="md"
showBar={true}
barProgress={60 + index * 10}
/>
</div>
);
})}
{statistics.map((stat, index) => (
<div
key={stat.label}
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
>
<StatCard
value={stat.value}
label={stat.label}
subtext={stat.subtext}
suffix={stat.suffix}
variant="brutalist"
size="md"
showBar={true}
barProgress={60 + index * 10}
/>
</div>
))}
</div>
{/* Activity Stream */}
<div className="max-w-3xl mx-auto">
<ActivityStream updateInterval={6000} maxItems={5} />
<PublicActivityStream />
</div>
</Container>
</section>

View file

@ -18,7 +18,13 @@ interface FeatureCardProps {
href?: string;
}
function FeatureCard({ icon, title, description, badge, href }: FeatureCardProps): React.ReactElement {
function FeatureCard({
icon,
title,
description,
badge,
href,
}: FeatureCardProps): React.ReactElement {
const content = (
<div className="group h-full p-6 border border-dashed border-border bg-surface hover:border-primary hover:bg-primary/5 transition-all duration-200">
{/* Icon */}
@ -60,13 +66,18 @@ function FeatureCard({ icon, title, description, badge, href }: FeatureCardProps
// Main Features Section
// ============================================================================
export function FeaturesSection(): React.ReactElement {
interface FeaturesSectionProps {
toolCount?: number;
}
export function FeaturesSection({ toolCount }: FeaturesSectionProps): React.ReactElement {
const toolCountLabel = toolCount && toolCount > 0 ? `${toolCount.toLocaleString()}` : '100+';
const features = [
{
icon: 'search',
title: 'tool registry',
description:
'Browse 1M+ AI tools from npm. Auto-discovered within minutes of publication with quality scoring and health monitoring.',
description: `Browse ${toolCountLabel} AI tools from npm. Auto-discovered within minutes of publication with quality scoring and health monitoring.`,
badge: 'auto-sync',
href: '/tool/tool-search',
},

View file

@ -10,6 +10,8 @@ interface HeroSectionProps {
packageCount: number;
toolCount: number;
categoryCount: number;
totalDownloads: number;
totalStars: number;
};
}
@ -79,6 +81,15 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
<span className="text-foreground">{formatNumber(stats.toolCount)}</span>
<span className="text-foreground-secondary">TOOLS</span>
</div>
{stats.totalDownloads > 0 && (
<>
<span className="text-foreground-tertiary">/</span>
<div className="flex items-center gap-2">
<span className="text-foreground">{formatNumber(stats.totalDownloads)}</span>
<span className="text-foreground-secondary">DOWNLOADS</span>
</div>
</>
)}
</div>
{/* Subheading */}

View file

@ -0,0 +1,80 @@
'use client';
import { ActivityStream } from '@tpmjs/ui/ActivityStream/ActivityStream';
import { useEffect, useRef, useState } from 'react';
interface ActivityItem {
type: 'invoked' | 'published' | 'updated';
tool: string;
time: string;
}
function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
interface PublicActivity {
id: string;
type: 'invoked' | 'published' | 'updated';
username: string;
targetName: string;
targetType: string;
createdAt: string;
}
export function PublicActivityStream(): React.ReactElement {
const [activities, setActivities] = useState<ActivityItem[]>([]);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
async function fetchActivities() {
try {
const res = await fetch('/api/activity/public');
const json = await res.json();
if (isMounted.current && json.success && json.data.length > 0) {
setActivities(
json.data.map((a: PublicActivity) => ({
type: a.type,
tool: `${a.username}${a.targetName}`,
time: formatRelativeTime(a.createdAt),
}))
);
}
} catch {
// Silent fail — activity stream is non-critical
}
}
// Initial fetch via interval (fires immediately on mount delay, then every 30s)
const timeoutId = setTimeout(fetchActivities, 0);
const intervalId = setInterval(fetchActivities, 30_000);
return () => {
isMounted.current = false;
clearTimeout(timeoutId);
clearInterval(intervalId);
};
}, []);
// Pass activities to the existing ActivityStream component
// If no real activities exist yet, ActivityStream will auto-generate mock activity
return (
<ActivityStream
activities={activities.length > 0 ? activities : undefined}
updateInterval={30_000}
maxItems={5}
/>
);
}

View file

@ -124,7 +124,10 @@ export function SkillsActivityFeed({
<div className="space-y-3">
{questions.map((q) => (
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
<Card variant="default" className="hover:border-primary/20 hover:bg-muted/30 transition-all cursor-pointer">
<Card
variant="default"
className="hover:border-primary/20 hover:bg-muted/30 transition-all cursor-pointer"
>
<CardHeader padding="sm" className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle as="h4" className="text-sm font-medium line-clamp-2">
@ -146,8 +149,8 @@ export function SkillsActivityFeed({
<div className="flex items-center justify-between">
<div className="flex gap-1.5 flex-wrap">
{q.skillNodes.slice(0, 2).map((sn, i) => (
<Badge key={i} variant="outline" size="sm">
{q.skillNodes.slice(0, 2).map((sn) => (
<Badge key={sn.skill.name} variant="outline" size="sm">
{sn.skill.name}
</Badge>
))}

View file

@ -120,13 +120,15 @@ export function SkillsStats({ collectionId }: SkillsStatsProps): React.ReactElem
</CardHeader>
<CardContent padding="sm" className="pt-0">
<div className="space-y-3">
{stats.topSkills.slice(0, 5).map((skill, i) => (
<div key={i} className="space-y-1">
{stats.topSkills.slice(0, 5).map((skill) => (
<div key={skill.name} className="space-y-1">
<div className="flex items-center justify-between">
<Badge variant="outline" size="sm" className="truncate max-w-[140px]">
{skill.name}
</Badge>
<span className="text-xs text-foreground-secondary">{skill.questionCount} Q</span>
<span className="text-xs text-foreground-secondary">
{skill.questionCount} Q
</span>
</div>
<ProgressBar
value={skill.confidence * 100}

View file

@ -0,0 +1,38 @@
'use client';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import { useState } from 'react';
export function TechDiagram() {
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
if (hasError) {
return (
<div className="w-full h-[700px] border border-border rounded-lg overflow-hidden flex flex-col items-center justify-center gap-4 p-8 text-center">
<p className="text-foreground-secondary">The interactive diagram failed to load.</p>
<a href="/architecture" className="text-blue-500 hover:underline text-sm">
View the alternative architecture diagram &rarr;
</a>
</div>
);
}
return (
<div className="w-full h-[700px] border border-border rounded-lg overflow-hidden relative bg-white">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-white">
<Spinner size="lg" label="Loading architecture diagram..." />
</div>
)}
<iframe
src="/isoflow-embed.html"
title="TPMJS Ecosystem Architecture Diagram"
className="w-full h-full border-0"
onLoad={() => setIsLoading(false)}
onError={() => setHasError(true)}
sandbox="allow-scripts allow-same-origin"
/>
</div>
);
}

View file

@ -11,22 +11,6 @@ export interface ToolCard {
href: string;
}
export interface Category {
id: string;
name: string;
icon: IconName;
colorClass: string;
toolCount: number;
href: string;
}
export interface Statistic {
icon: IconName;
value: string;
label: string;
subtext?: string;
}
export const featuredTools: ToolCard[] = [
{
id: 'tool-search',
@ -95,131 +79,8 @@ export const featuredTools: ToolCard[] = [
},
];
export const categories: Category[] = [
{
id: 'web-apis',
name: 'Web & APIs',
icon: 'externalLink',
colorClass: 'bg-blue-500 text-white',
toolCount: 423,
href: '/category/web-apis',
},
{
id: 'databases',
name: 'Databases',
icon: 'copy',
colorClass: 'bg-emerald-500 text-white',
toolCount: 198,
href: '/category/databases',
},
{
id: 'documents',
name: 'Documents',
icon: 'copy',
colorClass: 'bg-amber-500 text-white',
toolCount: 156,
href: '/category/documents',
},
{
id: 'images',
name: 'Images',
icon: 'check',
colorClass: 'bg-pink-500 text-white',
toolCount: 134,
href: '/category/images',
},
{
id: 'email',
name: 'Email',
icon: 'check',
colorClass: 'bg-indigo-500 text-white',
toolCount: 89,
href: '/category/email',
},
{
id: 'calendar',
name: 'Calendar',
icon: 'check',
colorClass: 'bg-orange-500 text-white',
toolCount: 67,
href: '/category/calendar',
},
{
id: 'search',
name: 'Search',
icon: 'check',
colorClass: 'bg-red-500 text-white',
toolCount: 112,
href: '/category/search',
},
{
id: 'code-execution',
name: 'Code Execution',
icon: 'github',
colorClass: 'bg-zinc-700 text-white',
toolCount: 245,
href: '/category/code-execution',
},
{
id: 'communication',
name: 'Communication',
icon: 'check',
colorClass: 'bg-cyan-500 text-white',
toolCount: 178,
href: '/category/communication',
},
{
id: 'analytics',
name: 'Analytics',
icon: 'check',
colorClass: 'bg-violet-500 text-white',
toolCount: 203,
href: '/category/analytics',
},
{
id: 'security',
name: 'Security',
icon: 'check',
colorClass: 'bg-slate-600 text-white',
toolCount: 91,
href: '/category/security',
},
{
id: 'workflows',
name: 'Workflows',
icon: 'check',
colorClass: 'bg-teal-500 text-white',
toolCount: 167,
href: '/category/workflows',
},
];
export const statistics: Statistic[] = [
{
icon: 'copy',
value: '2,847',
label: 'Published Tools',
subtext: 'Across 24 categories',
},
{
icon: 'github',
value: '48K+',
label: 'Active Developers',
subtext: 'Building with TPMJS',
},
{
icon: 'check',
value: '12M+',
label: 'Weekly Invocations',
subtext: 'Across all tools',
},
{
icon: 'chevronDown',
value: '47ms',
label: 'Average Response',
subtext: '95th percentile latency',
},
];
// Note: categories and statistics arrays were removed in favor of real data
// from the database. See EcosystemStats component and getHomePageData() in page.tsx.
export interface ProblemPoint {
title: string;

Some files were not shown because too many files have changed in this diff Show more