feat: add environment variables support for agents and collections

- Add envVars field to Agent and Collection models in Prisma schema
- Add envVars to UpdateAgentSchema and UpdateCollectionSchema types
- Implement env vars merging logic (agent overrides collection)
- Pass env vars through tool executor to sandbox
- Add UI for managing env vars in agent and collection settings pages
- Update API routes to handle envVars PATCH updates
- Refactor discord tool execute functions for biome compatibility

This allows users to configure tool-specific environment variables (like
DISCORD_BOT_TOKEN) at both the collection and agent level, with agent
settings taking precedence over collection settings.
This commit is contained in:
Ajax Davis 2026-01-12 01:35:29 +10:00
parent 079c328bfc
commit 938a382aab
25 changed files with 2021 additions and 15 deletions

844
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,844 @@
# TPMJS Architecture Documentation
A comprehensive guide to the TPMJS platform architecture - from tool discovery to sandboxed execution, collections, agents, and custom executors.
---
## Table of Contents
1. [Platform Overview](#1-platform-overview)
2. [Monorepo Structure](#2-monorepo-structure)
3. [Database Layer](#3-database-layer)
4. [Tool Execution System](#4-tool-execution-system)
5. [MCP Protocol Implementation](#5-mcp-protocol-implementation)
6. [Agent System](#6-agent-system)
7. [Collection System](#7-collection-system)
8. [NPM Sync System](#8-npm-sync-system)
9. [API Layer](#9-api-layer)
10. [SDK Packages](#10-sdk-packages)
11. [UI & Frontend](#11-ui--frontend)
12. [Security & Authentication](#12-security--authentication)
---
## 1. Platform Overview
TPMJS is a **tool registry platform** that automatically discovers, validates, and executes npm packages as AI agent tools. The platform supports multiple AI providers (OpenAI, Anthropic, Google, Groq, Mistral) and exposes tools via MCP (Model Context Protocol) for use with Claude Desktop, Cursor, and other MCP clients.
### High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ USER PRODUCTS │
├─────────────────────┬─────────────────────┬─────────────────────────────────┤
│ tpmjs.com │ SDK Packages │ MCP Protocol │
│ ───────────────── │ ───────────────── │ ───────────────────────────── │
│ • Dashboard │ • @tpmjs/types │ • Claude Desktop │
│ • Tool Browser │ • registry-search │ • Cursor │
│ • Collection Editor│ • registry-execute │ • Claude Code │
│ • Agent Builder │ │ • Any MCP Client │
│ • Playground │ │ │
└─────────────────────┴─────────────────────┴─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ API LAYER (Next.js 16) │
├─────────────────────────────────────────────────────────────────────────────┤
│ /api/tools /api/agents /api/collections /api/mcp/* /api/sync/* │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE │
├───────────────────────┬───────────────────────┬─────────────────────────────┤
│ Database │ Execution │ External │
│ ─────────────────── │ ─────────────────── │ ───────────────────────── │
│ • PostgreSQL (Neon) │ • Vercel Sandbox │ • npm Registry │
│ • Prisma ORM │ • Custom Executors │ • esm.sh CDN │
│ │ │ • GitHub API │
└───────────────────────┴───────────────────────┴─────────────────────────────┘
```
### Key Concepts
| Concept | Description |
|---------|-------------|
| **Tool** | A single executable function from an npm package |
| **Package** | An npm package containing one or more tools |
| **Collection** | A user-curated bundle of tools exposed via MCP |
| **Agent** | An AI assistant with access to tools and collections |
| **Executor** | A sandboxed environment for running tool code |
---
## 2. Monorepo Structure
TPMJS uses **Turborepo** with **pnpm** workspaces. The codebase is organized into packages and applications.
### Directory Structure
```
tpmjs/
├── apps/
│ ├── web/ # Main Next.js 16 application
│ ├── playground/ # Interactive tool testing
│ ├── tutorial/ # Tutorial application
│ └── railway-executor/ # Deno executor service
├── packages/
│ ├── ui/ # React component library (@tpmjs/ui)
│ ├── types/ # TypeScript types & Zod schemas (@tpmjs/types)
│ ├── utils/ # Utility functions (@tpmjs/utils)
│ ├── env/ # Environment validation (@tpmjs/env)
│ ├── db/ # Prisma database client (@tpmjs/db)
│ ├── npm-client/ # NPM Registry API client
│ ├── package-executor/ # Tool execution client
│ ├── config/ # Shared configs (Biome, ESLint, Tailwind, TS)
│ └── tools/ # 150+ official TPMJS tools
│ └── official/ # @tpmjs/tools-* packages
├── turbo.json # Turborepo task configuration
├── pnpm-workspace.yaml # Workspace definitions
└── vercel.json # Deployment & cron configuration
```
### Published Packages (npm @tpmjs scope)
| Package | Version | Purpose |
|---------|---------|---------|
| `@tpmjs/types` | 0.2.0 | TypeScript types and Zod validation schemas |
| `@tpmjs/utils` | 0.1.1 | Utility functions (cn, format helpers) |
| `@tpmjs/ui` | 0.1.3 | React component library (30+ components) |
| `@tpmjs/env` | 0.1.1 | Environment variable validation |
### Internal Packages
| Package | Purpose |
|---------|---------|
| `@tpmjs/db` | Prisma client and database schema |
| `@tpmjs/npm-client` | NPM Registry API client for syncing |
| `@tpmjs/package-executor` | Remote executor HTTP client |
| `@tpmjs/config` | Shared Biome, ESLint, Tailwind, TypeScript configs |
### Key Architecture Principles
1. **No Barrel Exports**: Components imported directly (`@tpmjs/ui/Button/Button`)
2. **Strict Module Boundaries**: Apps import from packages, not vice versa
3. **TypeScript Everywhere**: Strict mode with composite projects
4. **Shared Configurations**: Centralized in `packages/config/`
---
## 3. Database Layer
The database layer uses **Prisma ORM** with **PostgreSQL** (Neon) as the data store.
### Core Models
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ TOOL REGISTRY │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Package (1) ──────────────────────► (N) Tool │
│ ├── npmPackageName (unique) ├── id (PK) │
│ ├── npmVersion ├── name │
│ ├── category ├── description │
│ ├── tier (minimal|rich) ├── inputSchema (JSON) │
│ ├── npmDownloadsLastMonth ├── qualityScore │
│ └── githubStars ├── importHealth │
│ └── executionHealth │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ USER & SOCIAL │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User (1) ──────► (N) Agent ──────► (N) Conversation ──────► (N) Message │
│ │ │ │
│ │ └──────► (N) AgentTool │
│ │ └──────► (N) AgentCollection │
│ │ │
│ └──────► (N) Collection ──────► (N) CollectionTool │
│ │ │
│ └──────► (N) ToolLike, CollectionLike, AgentLike │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ SYNC & MONITORING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SyncCheckpoint SyncLog HealthCheck │
│ ├── source (unique) ├── source ├── toolId │
│ └── checkpoint (JSON) ├── status ├── importStatus │
│ ├── processed ├── executionStatus │
│ └── errors └── checkType │
│ │
│ Simulation TokenUsage StatsSnapshot │
│ ├── toolId ├── simulationId ├── date (unique) │
│ ├── status ├── inputTokens ├── totalTools │
│ └── output └── totalTokens └── healthStats │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Key Query Patterns
**1. Pagination without COUNT (limit+1 technique):**
```typescript
const tools = await prisma.tool.findMany({
take: limit + 1, // Fetch one extra to check hasMore
skip: offset,
});
const hasMore = tools.length > limit;
const actualTools = hasMore ? tools.slice(0, limit) : tools;
```
**2. Atomic Like/Unlike with Transactions:**
```typescript
const [like, updatedTool] = await prisma.$transaction([
prisma.toolLike.create({ data: { userId, toolId } }),
prisma.tool.update({
where: { id: toolId },
data: { likeCount: { increment: 1 } }
})
]);
```
**3. Upsert for Idempotent Sync Operations:**
```typescript
await prisma.package.upsert({
where: { npmPackageName: pkg.name },
create: { /* ... */ },
update: { /* ... */ }
});
```
---
## 4. Tool Execution System
The execution system provides sandboxed environments for safely running npm package tools.
### Execution Flow
```
┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 1. REQUEST │────►│ 2. RESOLVE │────►│ 3. EXECUTE │────►│ 4. RESPONSE │
├───────────────┤ ├───────────────┤ ├───────────────┤ ├───────────────┤
│ SDK: │ │ Lookup tool │ │ npm install │ │ output: any │
│ registryExec │ │ by ID │ │ pkg │ │ │
│ │ │ │ │ │ │ executionTime │
│ MCP: │ │ Resolve │ │ tool.execute │ │ Ms │
│ tools/call │ │ executor │ │ (params) │ │ │
│ │ │ config │ │ │ │ success: │
│ Agent: │ │ │ │ Return │ │ boolean │
│ tool_call │ │ Build import │ │ result │ │ │
│ │ │ URL │ │ │ │ │
└───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘
```
### Executor Types
**1. Default Executor (Vercel Sandbox)**
- Pre-configured sandbox environment
- Node.js 22, 2 vCPUs, 2 minute timeout
- Network isolated, per-request env injection
- Automatic npm install
**2. Custom URL Executor**
- User-deployed executor service
- Deploy to Vercel, Railway, AWS Lambda, or self-host
- Custom dependencies pre-installed
- Your own API keys built-in
### Executor Config Cascade
```
┌─────────────────────┐
│ System Default │ ◄─── Vercel Sandbox
│ (lowest priority) │
└─────────┬───────────┘
│ overridden by
┌─────────────────────┐
│ Collection Config │ ◄─── executorConfig on Collection
│ │
└─────────┬───────────┘
│ overridden by
┌─────────────────────┐
│ Agent Config │ ◄─── executorConfig on Agent
│ (highest priority) │
└─────────────────────┘
```
### Executor API Contract
All executors must implement:
**POST /execute-tool**
```typescript
interface ExecuteToolRequest {
packageName: string; // "@tpmjs/hello"
name: string; // "helloWorldTool"
version?: string; // "1.0.0" or "latest"
params: Record<string, unknown>;
env?: Record<string, string>;
}
interface ExecuteToolResponse {
success: boolean;
output?: unknown;
error?: string;
executionTimeMs: number;
}
```
**GET /health**
```typescript
interface HealthResponse {
status: 'ok' | 'degraded' | 'error';
version?: string;
}
```
---
## 5. MCP Protocol Implementation
TPMJS implements the **Model Context Protocol (MCP)** to expose collections as tool servers for AI clients.
### MCP Endpoints
| Transport | Endpoint | Purpose |
|-----------|----------|---------|
| HTTP | `/api/mcp/{username}/{slug}/http` | Request-response |
| SSE | `/api/mcp/{username}/{slug}/sse` | Streaming |
### JSON-RPC Methods
**initialize** - Returns server capabilities
```json
{
"protocolVersion": "2024-11-05",
"serverInfo": { "name": "TPMJS: My Collection", "version": "1.0.0" },
"capabilities": { "tools": {} }
}
```
**tools/list** - Returns available tools in collection
```json
{
"tools": [{
"name": "tpmjs-hello--helloWorldTool",
"description": "A simple hello world tool",
"inputSchema": { "type": "object", "properties": { ... } }
}]
}
```
**tools/call** - Executes a tool
```json
{
"content": [{ "type": "text", "text": "Hello World!" }]
}
```
### Tool Name Format
MCP tool names are sanitized from npm package names:
```
@tpmjs/hello + helloWorldTool → tpmjs-hello--helloWorldTool
```
---
## 6. Agent System
Agents are AI-powered assistants with multi-turn conversations and tool access.
### Agent Configuration
```typescript
interface Agent {
// Identity
id: string;
uid: string; // URL-friendly ID
name: string;
description?: string;
// Model Configuration
provider: 'OPENAI' | 'ANTHROPIC' | 'GOOGLE' | 'GROQ' | 'MISTRAL';
modelId: string; // e.g., "gpt-4o", "claude-3-5-sonnet"
systemPrompt?: string;
temperature: number; // 0-2, default 0.7
// Behavior
maxToolCallsPerTurn: number; // 1-100, default 20
maxMessagesInContext: number; // 1-100, default 10
// Visibility
isPublic: boolean;
// Executor Override
executorType?: 'default' | 'custom_url';
executorConfig?: { url: string; apiKey?: string };
// Relations
collections: AgentCollection[];
tools: AgentTool[];
}
```
### Conversation Flow
```
User Message
┌────────────────────────────────────┐
│ Save MESSAGE (role=USER) │
└────────────────┬───────────────────┘
┌────────────────────────────────────┐
│ Fetch message history │
│ (maxMessagesInContext) │
└────────────────┬───────────────────┘
┌────────────────────────────────────┐
│ Build AI SDK messages + tools │
│ • System prompt │
│ • Conversation history │
│ • Tool definitions │
└────────────────┬───────────────────┘
┌────────────────────────────────────┐
│ streamText() with tool use │
│ • SSE chunks to client │
│ • Tool calls executed │
│ • Results fed back to model │
└────────────────┬───────────────────┘
┌────────────────────────────────────┐
│ Save MESSAGE (role=ASSISTANT) │
│ Save MESSAGE (role=TOOL) for each │
│ tool call result │
└────────────────────────────────────┘
```
### SSE Event Types
| Event | Description |
|-------|-------------|
| `chunk` | Text token from AI |
| `tool_call` | AI decided to call a tool |
| `tool_result` | Tool execution completed |
| `tokens` | Token usage statistics |
| `complete` | Conversation finished |
| `error` | Error occurred |
---
## 7. Collection System
Collections are user-curated bundles of tools that can be shared and exposed via MCP.
### Collection Structure
```typescript
interface Collection {
id: string;
name: string;
slug: string; // URL-friendly, unique per user
description?: string;
isPublic: boolean;
// Executor Override (applies to all tools)
executorType?: 'default' | 'custom_url';
executorConfig?: { url: string; apiKey?: string };
// Relations
tools: CollectionTool[]; // Junction table with position, notes
}
interface CollectionTool {
toolId: string;
position: number; // User-defined ordering
note?: string; // User notes about the tool
}
```
### Collection Limits
| Limit | Value |
|-------|-------|
| Max collections per user | 50 |
| Max tools per collection | 100 |
| Max name length | 100 chars |
| Max description length | 500 chars |
### MCP Access URLs
Public collections can be accessed via MCP:
```
HTTP: https://tpmjs.com/api/mcp/{username}/{slug}/http
SSE: https://tpmjs.com/api/mcp/{username}/{slug}/sse
```
---
## 8. NPM Sync System
TPMJS automatically discovers tools from npm using multiple sync strategies.
### Sync Jobs
| Job | Schedule | Purpose |
|-----|----------|---------|
| Changes Feed | Every 2 min | Monitor npm real-time updates |
| Keyword Search | Every 15 min | Search for `tpmjs` keyword |
| Metrics | Every hour | Update downloads & quality scores |
| Health Check | Daily | Verify tool import/execution |
| Stats Snapshot | Daily | Capture historical statistics |
### Discovery Flow
```
npm Registry
├──► Changes Feed (/api/sync/changes)
│ • Polls /_changes endpoint
│ • 30 packages per run
│ • Checkpoint-based (lastSeq)
└──► Keyword Search (/api/sync/keyword)
• Searches for keyword:tpmjs
• 250 packages per run
• Backup discovery
┌────────────────────────────────────┐
│ Validate tpmjs field │
│ • Multi-tool format (new) │
│ • Legacy rich format │
│ • Legacy minimal format │
└────────────────┬───────────────────┘
┌────────────────────────────────────┐
│ Auto-discover tools │
│ • If tools[] missing/empty │
│ • Call executor listToolExports │
│ • Extract JSON schemas │
└────────────────┬───────────────────┘
┌────────────────────────────────────┐
│ Update database │
│ • Upsert Package record │
│ • Upsert Tool records │
│ • Trigger health checks │
└────────────────────────────────────┘
```
### Quality Score Calculation
```typescript
qualityScore = tierScore + downloadsScore + starsScore + richnessScore
// tierScore: 0.6 (rich) or 0.4 (minimal)
// downloadsScore: log10(downloads) / 15, max 0.2
// starsScore: log10(stars) / 10, max 0.1
// richnessScore: +0.04 (params) +0.03 (returns) +0.03 (aiAgent)
// Range: 0.00 - 1.00
```
### tpmjs Field Specification
**Multi-Tool Format (Recommended):**
```json
{
"tpmjs": {
"category": "utilities",
"tools": [
{
"name": "helloWorld",
"description": "Greets a user by name"
},
{
"name": "goodbye",
"description": "Says goodbye to a user"
}
],
"frameworks": ["vercel-ai"]
}
}
```
**Valid Categories:**
- Core: `research`, `web`, `data`, `documentation`, `engineering`, `security`, `statistics`, `ops`, `agent`, `utilities`, `html`, `compliance`
- Legacy: `web-scraping`, `data-processing`, `file-operations`, `communication`, `database`, `api-integration`, `image-processing`, `text-analysis`, `automation`, `ai-ml`, `monitoring`
---
## 9. API Layer
The API is built on Next.js 16 App Router with standardized response formats.
### Response Format
**Success:**
```typescript
{
success: true,
data: T,
meta: {
version: "1.0.0",
timestamp: "2025-01-11T...",
requestId: "uuid"
},
pagination?: {
limit: number,
offset: number,
count: number,
hasMore: boolean
}
}
```
**Error:**
```typescript
{
success: false,
error: {
code: "VALIDATION_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | ...,
message: "Human-readable message",
details?: { ... }
},
meta: { ... }
}
```
### Key Endpoints
| Category | Endpoint | Purpose |
|----------|----------|---------|
| **Tools** | `GET /api/tools` | List/search tools |
| | `POST /api/tools/execute/[...slug]` | Execute tool (SSE) |
| **Agents** | `GET /api/agents` | List user agents |
| | `POST /api/agents/[id]/conversation/[convId]` | Chat with agent (SSE) |
| **Collections** | `GET /api/collections` | List user collections |
| | `POST /api/collections/[id]/tools` | Add tool to collection |
| **MCP** | `POST /api/mcp/{user}/{slug}/{transport}` | MCP protocol |
| **Sync** | `POST /api/sync/changes` | Cron: npm changes |
| **Stats** | `GET /api/stats` | Registry statistics |
### Rate Limiting
| Endpoint Type | Limit | Window |
|---------------|-------|--------|
| Default | 100 requests | 1 minute |
| Strict | 20 requests | 1 minute |
| Tool Execute | 10 requests | 1 hour |
| Conversation | 30 requests | 1 minute |
### Authentication
- **Library:** `better-auth` with Prisma adapter
- **Session:** 7-day expiry, cookie-based
- **Email:** Verification required for login
- **Protected Routes:** Check `auth.api.getSession()`
---
## 10. SDK Packages
### @tpmjs/types
Core TypeScript types and Zod validation schemas.
**Exports:**
- `./tool` - Tool and ToolParameter schemas
- `./registry` - Search result schemas
- `./tpmjs` - tpmjs field validation (validateTpmjsField)
- `./agent` - Agent configuration schemas
- `./collection` - Collection schemas
- `./user` - User profile schemas
- `./executor` - Executor request/response types
### @tpmjs/npm-client (Internal)
NPM Registry API client for sync operations.
**Functions:**
- `fetchChanges()` - Poll changes feed
- `searchByKeyword()` - Search packages
- `fetchLatestPackageWithMetadata()` - Get package info
- `fetchDownloadStats()` - Get npm downloads
- `fetchGitHubStars()` - Get GitHub stars
### @tpmjs/package-executor (Internal)
Remote executor client for tool execution.
**Functions:**
- `executePackage(packageName, functionName, params)` - Execute tool
- `clearCache()` - Clear executor cache
- `checkHealth()` - Check executor health
---
## 11. UI & Frontend
### Component Library (@tpmjs/ui)
30+ React components with no-barrel-exports architecture.
**Categories:**
- **Form:** Button, Input, Select, Checkbox, Radio, Switch, Textarea, Slider
- **Layout:** Card, Container, Section, GridContainer, Header
- **Display:** Badge, ProgressBar, Spinner, Icon, CodeBlock, Table
- **Advanced:** Tabs, AnimatedCounter, StatCard, ActivityStream, FlowDiagram
### Design System
**Color System (CSS Variables):**
```css
/* Backgrounds */
--background, --surface, --surface-secondary, --surface-elevated
/* Text */
--foreground, --foreground-secondary, --foreground-tertiary, --foreground-muted
/* Interactive */
--primary, --secondary, --accent
/* Status */
--success, --error, --warning, --info
/* Borders */
--border, --border-strong
```
**Theme Support:**
- Light mode (default)
- Dark mode (Vercel/Cursor aesthetic)
- `next-themes` provider
### Dashboard Structure
```
/dashboard
├── Overview # Quick actions, profile, activity
├── Agents # Create/manage AI agents
│ └── [id]/chat # Chat interface
├── Collections # Organize tools
├── Settings
│ └── api-keys # Manage API keys
└── Likes
├── tools
├── collections
└── agents
```
---
## 12. Security & Authentication
### Authentication Flow
```
Sign Up → Email Verification → Sign In → Session Cookie → Protected Routes
```
### API Key Storage
User API keys (OpenAI, Anthropic, etc.) are stored encrypted:
- AES-256-CBC encryption
- Unique IV per key
- Only hint (last 4 chars) visible in UI
### Rate Limiting
- **Distributed:** Vercel KV with in-memory fallback
- **Per-IP:** Based on `x-forwarded-for`, `x-real-ip`, or `cf-connecting-ip`
- **Headers:** `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After`
### Cron Security
All sync endpoints require:
```
Authorization: Bearer {CRON_SECRET}
```
Vercel Cron automatically adds this header.
### Executor Verification
Custom executor URLs are verified:
1. HTTPS required in production
2. Private IP ranges blocked
3. Health endpoint checked
4. Test tool execution validated
---
## Quick Reference
### Environment Variables
| Variable | Required | Purpose |
|----------|----------|---------|
| `DATABASE_URL` | Yes | PostgreSQL connection |
| `BETTER_AUTH_SECRET` | Yes | Session encryption (32+ chars) |
| `CRON_SECRET` | Yes | Cron job auth (32+ chars) |
| `SANDBOX_EXECUTOR_URL` | No | Default executor URL |
| `GITHUB_TOKEN` | No | GitHub API for stars |
### Commands
```bash
# Development
pnpm dev # Run all dev servers
pnpm --filter=@tpmjs/web dev # Run web app only
# Database
pnpm --filter=@tpmjs/db db:generate # Generate Prisma client
pnpm --filter=@tpmjs/db db:push # Push schema changes
pnpm --filter=@tpmjs/db db:studio # Open Prisma Studio
# Testing
pnpm test # Run all tests
pnpm type-check # Type-check all packages
pnpm lint # Lint all packages
# Building
pnpm build # Build all packages
```
### Tech Stack
| Category | Technology |
|----------|------------|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript 5.9 (strict) |
| Database | PostgreSQL + Prisma 6.19 |
| Auth | better-auth 1.4 |
| AI SDK | Vercel AI SDK 6.0 |
| Styling | Tailwind CSS 4.1 |
| Build | Turborepo + pnpm |
| Testing | Vitest + Testing Library |
| Deployment | Vercel |
---
*This documentation was auto-generated from codebase exploration. Last updated: January 2025*

View file

@ -178,13 +178,16 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
} }
} }
// Build update data, transforming executorConfig for Prisma (null -> Prisma.JsonNull) // Build update data, transforming JSON fields for Prisma (null -> Prisma.JsonNull)
const { executorConfig, ...restData } = parsed.data; const { executorConfig, envVars, ...restData } = parsed.data;
const updateData: Prisma.AgentUpdateInput = { const updateData: Prisma.AgentUpdateInput = {
...restData, ...restData,
...(executorConfig !== undefined && { ...(executorConfig !== undefined && {
executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig, executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig,
}), }),
...(envVars !== undefined && {
envVars: envVars === null ? Prisma.JsonNull : envVars,
}),
}; };
const agent = await prisma.agent.update({ const agent = await prisma.agent.update({

View file

@ -173,6 +173,7 @@ export async function GET(
* PATCH /api/collections/[id] * PATCH /api/collections/[id]
* Update a collection * Update a collection
*/ */
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multiple validation checks required
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
context: RouteContext context: RouteContext
@ -243,7 +244,7 @@ export async function PATCH(
); );
} }
const { name, description, isPublic, executorType, executorConfig } = parseResult.data; const { name, description, isPublic, executorType, executorConfig, envVars } = parseResult.data;
// If name is being changed, check for duplicates // If name is being changed, check for duplicates
if (name && name !== existingCollection.name) { if (name && name !== existingCollection.name) {
@ -281,6 +282,9 @@ export async function PATCH(
...(executorConfig !== undefined && { ...(executorConfig !== undefined && {
executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig, executorConfig: executorConfig === null ? Prisma.JsonNull : executorConfig,
}), }),
...(envVars !== undefined && {
envVars: envVars === null ? Prisma.JsonNull : envVars,
}),
}, },
include: { include: {
_count: { select: { tools: true } }, _count: { select: { tools: true } },

View file

@ -0,0 +1,188 @@
/**
* Discord Summary Cron Endpoint
*
* Triggers the Discord summary agent to:
* 1. Read messages from the past 24 hours
* 2. Summarize them
* 3. Post the summary to a Discord channel
*
* Schedule: Daily at 9 AM UTC (configured in vercel.json)
*/
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { env } from '~/env';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300; // 5 minutes for agent execution
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Cron job with multiple steps and error handling
export async function POST(request: NextRequest) {
const startTime = Date.now();
// 1. Verify cron secret
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 });
}
// 2. Check required env vars
const agentId = env.DISCORD_SUMMARY_AGENT_ID;
const guildId = env.DISCORD_GUILD_ID;
const summaryChannelId = env.DISCORD_SUMMARY_CHANNEL_ID;
if (!agentId || !guildId || !summaryChannelId) {
const missing = [];
if (!agentId) missing.push('DISCORD_SUMMARY_AGENT_ID');
if (!guildId) missing.push('DISCORD_GUILD_ID');
if (!summaryChannelId) missing.push('DISCORD_SUMMARY_CHANNEL_ID');
return NextResponse.json(
{ success: false, error: `Missing required env vars: ${missing.join(', ')}` },
{ status: 500 }
);
}
try {
// 3. Verify agent exists
const agent = await prisma.agent.findUnique({
where: { id: agentId },
select: { id: true, name: true },
});
if (!agent) {
return NextResponse.json(
{ success: false, error: `Agent not found: ${agentId}` },
{ status: 404 }
);
}
// 4. Get or create a conversation for this cron job
// Use a fixed slug for the cron job so we reuse the same conversation
const cronSlug = 'discord-summary-cron';
let conversation = await prisma.conversation.findUnique({
where: { agentId_slug: { agentId, slug: cronSlug } },
});
if (!conversation) {
conversation = await prisma.conversation.create({
data: {
agentId,
slug: cronSlug,
title: 'Daily Discord Summary',
},
});
}
// 5. Build the prompt for the agent
const prompt = `Please summarize the Discord server activity from the past 24 hours.
Use the discordReadTool with these parameters:
- guildId: "${guildId}"
- hours: 24
- excludeBots: true
After reading the messages, analyze them and create a summary that includes:
- Key discussions and topics from each active channel
- Important decisions or announcements
- Action items or follow-ups mentioned
- Notable conversations or questions
Then use the discordPostTool to post the summary to channel "${summaryChannelId}" as a rich embed with:
- title: "📊 Daily Server Summary"
- color: 5793266 (Discord blurple)
- A well-formatted description with the summary
- footer: Include the date range and message count
If there are no messages in the past 24 hours, post a brief message saying the server was quiet.`;
// 6. Call the agent conversation endpoint
const baseUrl = env.BETTER_AUTH_URL || `http://localhost:${process.env.PORT || 3000}`;
const response = await fetch(
`${baseUrl}/api/agents/${agentId}/conversation/${conversation.id}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: prompt }),
}
);
// 7. Consume the SSE stream to let it complete
// We don't need to parse the events, just ensure the request completes
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const { done } = await reader.read();
if (done) break;
}
} finally {
reader.releaseLock();
}
}
const durationMs = Date.now() - startTime;
// 8. Log the result
await prisma.syncLog.create({
data: {
source: 'discord-summary',
status: response.ok ? 'success' : 'error',
processed: 1,
skipped: 0,
errors: response.ok ? 0 : 1,
message: response.ok
? 'Discord summary completed'
: `Agent request failed: ${response.status}`,
metadata: {
durationMs,
agentId,
conversationId: conversation.id,
guildId,
summaryChannelId,
},
},
});
return NextResponse.json({
success: response.ok,
data: {
agentId,
agentName: agent.name,
conversationId: conversation.id,
durationMs,
},
});
} catch (error) {
const durationMs = Date.now() - startTime;
// Log error
await prisma.syncLog.create({
data: {
source: 'discord-summary',
status: 'error',
processed: 0,
skipped: 0,
errors: 1,
message: error instanceof Error ? error.message : 'Unknown error',
metadata: {
durationMs,
agentId,
guildId,
summaryChannelId,
},
},
});
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -20,8 +20,8 @@ import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
interface Agent { interface Agent {
id: string; id: string;
@ -37,6 +37,7 @@ interface Agent {
isPublic: boolean; isPublic: boolean;
executorType: string | null; executorType: string | null;
executorConfig: { url: string; apiKey?: string } | null; executorConfig: { url: string; apiKey?: string } | null;
envVars: Record<string, string> | null;
toolCount: number; toolCount: number;
collectionCount: number; collectionCount: number;
createdAt: string; createdAt: string;
@ -302,6 +303,12 @@ export default function AgentDetailPage(): React.ReactElement {
isPublic: true, isPublic: true,
}); });
// Environment variables state (separate from form to handle key-value pairs)
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([]);
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvValue, setNewEnvValue] = useState('');
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multiple state initialization from fetched data
const fetchAgent = useCallback(async () => { const fetchAgent = useCallback(async () => {
try { try {
const response = await fetch(`/api/agents/${agentId}`); const response = await fetch(`/api/agents/${agentId}`);
@ -331,6 +338,17 @@ export default function AgentDetailPage(): React.ReactElement {
} else { } else {
setExecutorConfig(data.data.executorType ? { type: 'default' } : null); setExecutorConfig(data.data.executorType ? { type: 'default' } : null);
} }
// Initialize env vars from agent data
if (data.data.envVars && typeof data.data.envVars === 'object') {
setEnvVars(
Object.entries(data.data.envVars).map(([key, value]) => ({
key,
value: String(value),
}))
);
} else {
setEnvVars([]);
}
} else { } else {
if (response.status === 401) { if (response.status === 401) {
router.push('/sign-in'); router.push('/sign-in');
@ -589,6 +607,7 @@ export default function AgentDetailPage(): React.ReactElement {
}); });
}; };
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multiple conditional fields in save payload
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
@ -619,6 +638,15 @@ export default function AgentDetailPage(): React.ReactElement {
updatePayload.executorConfig = null; updatePayload.executorConfig = null;
} }
// Add env vars - convert array back to object
const envVarsObject: Record<string, string> = {};
for (const { key, value } of envVars) {
if (key.trim()) {
envVarsObject[key.trim()] = value;
}
}
updatePayload.envVars = Object.keys(envVarsObject).length > 0 ? envVarsObject : null;
try { try {
const response = await fetch(`/api/agents/${agentId}`, { const response = await fetch(`/api/agents/${agentId}`, {
method: 'PATCH', method: 'PATCH',
@ -907,6 +935,93 @@ export default function AgentDetailPage(): React.ReactElement {
/> />
</div> </div>
{/* Environment Variables */}
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-foreground">Environment Variables</h3>
<p className="text-xs text-foreground-tertiary mt-0.5">
Passed to tools at runtime. Agent vars override collection vars.
</p>
</div>
</div>
{/* Existing env vars */}
{envVars.length > 0 && (
<div className="space-y-2 mb-3">
{envVars.map((env, index) => (
<div key={`env-${env.key || index}`} className="flex items-center gap-2">
<input
type="text"
value={env.key}
onChange={(e) => {
const updated = [...envVars];
updated[index] = { key: e.target.value, value: env.value };
setEnvVars(updated);
}}
placeholder="KEY"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<input
type="password"
value={env.value}
onChange={(e) => {
const updated = [...envVars];
updated[index] = { key: env.key, value: e.target.value };
setEnvVars(updated);
}}
placeholder="value"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEnvVars(envVars.filter((_, i) => i !== index));
}}
title="Remove"
>
<Icon icon="trash" size="xs" />
</Button>
</div>
))}
</div>
)}
{/* Add new env var */}
<div className="flex items-center gap-2">
<input
type="text"
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
placeholder="NEW_KEY"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<input
type="text"
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
placeholder="value"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<Button
size="sm"
variant="secondary"
onClick={() => {
if (newEnvKey.trim()) {
setEnvVars([...envVars, { key: newEnvKey.trim(), value: newEnvValue }]);
setNewEnvKey('');
setNewEnvValue('');
}
}}
disabled={!newEnvKey.trim()}
>
<Icon icon="plus" size="xs" className="mr-1" />
Add
</Button>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-4"> <div className="flex items-center justify-end gap-2 pt-4">
<Button <Button
variant="outline" variant="outline"
@ -934,6 +1049,19 @@ export default function AgentDetailPage(): React.ReactElement {
} else { } else {
setExecutorConfig(agent.executorType ? { type: 'default' } : null); setExecutorConfig(agent.executorType ? { type: 'default' } : null);
} }
// Reset env vars to agent's current value
if (agent.envVars && typeof agent.envVars === 'object') {
setEnvVars(
Object.entries(agent.envVars).map(([key, value]) => ({
key,
value: String(value),
}))
);
} else {
setEnvVars([]);
}
setNewEnvKey('');
setNewEnvValue('');
}} }}
> >
Cancel Cancel

View file

@ -7,11 +7,11 @@ import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
import { AddToolSearch } from '~/components/collections/AddToolSearch'; import { AddToolSearch } from '~/components/collections/AddToolSearch';
import { CollectionForm } from '~/components/collections/CollectionForm'; import { CollectionForm } from '~/components/collections/CollectionForm';
import { CollectionToolList } from '~/components/collections/CollectionToolList'; import { CollectionToolList } from '~/components/collections/CollectionToolList';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
function McpUrlSection({ collectionId }: { collectionId: string }) { function McpUrlSection({ collectionId }: { collectionId: string }) {
const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null); const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
@ -169,6 +169,7 @@ interface Collection {
toolCount: number; toolCount: number;
executorType: string | null; executorType: string | null;
executorConfig: { url: string; apiKey?: string } | null; executorConfig: { url: string; apiKey?: string } | null;
envVars: Record<string, string> | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isOwner: boolean; isOwner: boolean;
@ -190,6 +191,11 @@ export default function CollectionDetailPage(): React.ReactElement {
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null); const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null);
// Environment variables state
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([]);
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvValue, setNewEnvValue] = useState('');
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Fetch callback with error handling // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Fetch callback with error handling
const fetchCollection = useCallback(async () => { const fetchCollection = useCallback(async () => {
try { try {
@ -208,6 +214,17 @@ export default function CollectionDetailPage(): React.ReactElement {
} else { } else {
setExecutorConfig(data.data.executorType ? { type: 'default' } : null); setExecutorConfig(data.data.executorType ? { type: 'default' } : null);
} }
// Initialize env vars from collection data
if (data.data.envVars && typeof data.data.envVars === 'object') {
setEnvVars(
Object.entries(data.data.envVars).map(([key, value]) => ({
key,
value: String(value),
}))
);
} else {
setEnvVars([]);
}
} else { } else {
if (data.error?.code === 'UNAUTHORIZED') { if (data.error?.code === 'UNAUTHORIZED') {
router.push('/sign-in'); router.push('/sign-in');
@ -231,6 +248,7 @@ export default function CollectionDetailPage(): React.ReactElement {
fetchCollection(); fetchCollection();
}, [fetchCollection]); }, [fetchCollection]);
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multiple conditional fields in update payload
const handleUpdate = async (data: { name: string; description?: string; isPublic: boolean }) => { const handleUpdate = async (data: { name: string; description?: string; isPublic: boolean }) => {
if (!collection) return; if (!collection) return;
setIsUpdating(true); setIsUpdating(true);
@ -252,6 +270,15 @@ export default function CollectionDetailPage(): React.ReactElement {
updatePayload.executorConfig = null; updatePayload.executorConfig = null;
} }
// Add env vars - convert array to object
const envVarsObject: Record<string, string> = {};
for (const { key, value } of envVars) {
if (key.trim()) {
envVarsObject[key.trim()] = value;
}
}
updatePayload.envVars = Object.keys(envVarsObject).length > 0 ? envVarsObject : null;
try { try {
const response = await fetch(`/api/collections/${collectionId}`, { const response = await fetch(`/api/collections/${collectionId}`, {
method: 'PATCH', method: 'PATCH',
@ -475,6 +502,93 @@ export default function CollectionDetailPage(): React.ReactElement {
disabled={isUpdating} disabled={isUpdating}
/> />
</div> </div>
{/* Environment Variables */}
<div className="mt-6 pt-6 border-t border-border">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-foreground">Environment Variables</h3>
<p className="text-xs text-foreground-tertiary mt-0.5">
Passed to tools at runtime. Agent vars override collection vars.
</p>
</div>
</div>
{/* Existing env vars */}
{envVars.length > 0 && (
<div className="space-y-2 mb-3">
{envVars.map((env, index) => (
<div key={`env-${env.key || index}`} className="flex items-center gap-2">
<input
type="text"
value={env.key}
onChange={(e) => {
const updated = [...envVars];
updated[index] = { key: e.target.value, value: env.value };
setEnvVars(updated);
}}
placeholder="KEY"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<input
type="password"
value={env.value}
onChange={(e) => {
const updated = [...envVars];
updated[index] = { key: env.key, value: e.target.value };
setEnvVars(updated);
}}
placeholder="value"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEnvVars(envVars.filter((_, i) => i !== index));
}}
title="Remove"
>
<Icon icon="trash" size="xs" />
</Button>
</div>
))}
</div>
)}
{/* Add new env var */}
<div className="flex items-center gap-2">
<input
type="text"
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
placeholder="NEW_KEY"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<input
type="text"
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
placeholder="value"
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
/>
<Button
size="sm"
variant="secondary"
onClick={() => {
if (newEnvKey.trim()) {
setEnvVars([...envVars, { key: newEnvKey.trim(), value: newEnvValue }]);
setNewEnvKey('');
setNewEnvValue('');
}
}}
disabled={!newEnvKey.trim()}
>
<Icon icon="plus" size="xs" className="mr-1" />
Add
</Button>
</div>
</div>
</div> </div>
)} )}

View file

@ -16,4 +16,9 @@ export const env = createEnv({
// Resend (Email) // Resend (Email)
RESEND_API_KEY: z.string().startsWith('re_').optional(), // Resend API key for sending emails RESEND_API_KEY: z.string().startsWith('re_').optional(), // Resend API key for sending emails
// Discord Summary Agent
DISCORD_SUMMARY_AGENT_ID: z.string().optional(), // Agent ID for Discord summary cron
DISCORD_GUILD_ID: z.string().optional(), // Discord server ID to summarize
DISCORD_SUMMARY_CHANNEL_ID: z.string().optional(), // Channel to post summaries to
}); });

View file

@ -192,6 +192,38 @@ function sanitizeToolName(name: string): string {
return sanitized.slice(0, 64); return sanitized.slice(0, 64);
} }
/**
* Parse environment variables from JSON field
* Returns empty object if null/undefined or invalid
*/
function parseEnvVars(envVars: unknown): Record<string, string> {
if (!envVars || typeof envVars !== 'object') {
return {};
}
// Validate all values are strings
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(envVars)) {
if (typeof value === 'string') {
result[key] = value;
}
}
return result;
}
/**
* Merge environment variables with agent envVars taking precedence
* Collection envVars are used as base, agent envVars override
*/
function mergeEnvVars(
collectionEnvVars: Record<string, string>,
agentEnvVars: Record<string, string>
): Record<string, string> {
return {
...collectionEnvVars,
...agentEnvVars, // Agent overrides collection
};
}
/** /**
* Build all tools from an agent's collections and individual tools * Build all tools from an agent's collections and individual tools
* Returns a map of tool name -> AI SDK tool definition * Returns a map of tool name -> AI SDK tool definition
@ -200,6 +232,11 @@ function sanitizeToolName(name: string): string {
* - If agent has an executor config, it's used for all tools * - If agent has an executor config, it's used for all tools
* - If agent has no config but collection has one, collection's config is used for tools from that collection * - If agent has no config but collection has one, collection's config is used for tools from that collection
* - If neither has config, system default is used * - If neither has config, system default is used
*
* Environment variables cascade: Agent Collection (merged, agent overrides)
* - Collection env vars are used as base
* - Agent env vars override collection env vars for same keys
* - Both are merged together for unique keys
*/ */
export function buildAgentTools( export function buildAgentTools(
agent: AgentWithRelations agent: AgentWithRelations
@ -210,6 +247,9 @@ export function buildAgentTools(
// Parse agent-level executor config // Parse agent-level executor config
const agentExecutorConfig = parseExecutorConfig(agent.executorType, agent.executorConfig); const agentExecutorConfig = parseExecutorConfig(agent.executorType, agent.executorConfig);
// Parse agent-level env vars
const agentEnvVars = parseEnvVars(agent.envVars);
// Add tools from collections first // Add tools from collections first
for (const agentCollection of agent.collections) { for (const agentCollection of agent.collections) {
const collection = agentCollection.collection; const collection = agentCollection.collection;
@ -223,6 +263,10 @@ export function buildAgentTools(
// Resolve executor config: Agent → Collection → Default // Resolve executor config: Agent → Collection → Default
const resolvedConfig = resolveExecutorConfig(agentExecutorConfig, collectionExecutorConfig); const resolvedConfig = resolveExecutorConfig(agentExecutorConfig, collectionExecutorConfig);
// Parse collection-level env vars and merge with agent env vars
const collectionEnvVars = parseEnvVars(collection.envVars);
const mergedEnvVars = mergeEnvVars(collectionEnvVars, agentEnvVars);
for (const collectionTool of collection.tools) { for (const collectionTool of collection.tools) {
const tool = collectionTool.tool; const tool = collectionTool.tool;
const toolKey = `${tool.package.npmPackageName}::${tool.name}`; const toolKey = `${tool.package.npmPackageName}::${tool.name}`;
@ -232,12 +276,13 @@ export function buildAgentTools(
seenTools.add(toolKey); seenTools.add(toolKey);
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`); const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
tools[toolName] = createToolDefinition(tool, resolvedConfig); tools[toolName] = createToolDefinition(tool, resolvedConfig, mergedEnvVars);
} }
} }
// Add individual tools (may override collection tools) // Add individual tools (may override collection tools)
// Individual tools use agent config or system default (no collection context) // Individual tools use agent config or system default (no collection context)
// Individual tools only use agent env vars (no collection context)
const individualToolConfig = agentExecutorConfig ?? { type: 'default' as const }; const individualToolConfig = agentExecutorConfig ?? { type: 'default' as const };
for (const agentTool of agent.tools) { for (const agentTool of agent.tools) {
@ -249,7 +294,7 @@ export function buildAgentTools(
seenTools.add(toolKey); seenTools.add(toolKey);
const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`); const toolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.name}`);
tools[toolName] = createToolDefinition(tool, individualToolConfig); tools[toolName] = createToolDefinition(tool, individualToolConfig, agentEnvVars);
} }
return tools; return tools;

View file

@ -96,10 +96,12 @@ export function tpmjsParamsToZodSchema(parameters: TPMJSParameter[]): z.ZodObjec
* *
* @param tool - The tool with its package relation * @param tool - The tool with its package relation
* @param executorConfig - Optional executor config for custom executors * @param executorConfig - Optional executor config for custom executors
* @param envVars - Optional environment variables to pass to the tool
*/ */
export function createToolDefinition( export function createToolDefinition(
tool: Tool & { package: Package }, tool: Tool & { package: Package },
executorConfig?: ExecutorConfig | null executorConfig?: ExecutorConfig | null,
envVars?: Record<string, string>
) { ) {
console.log('[createToolDefinition] Tool:', tool.package.npmPackageName, '/', tool.name); console.log('[createToolDefinition] Tool:', tool.package.npmPackageName, '/', tool.name);
@ -128,6 +130,9 @@ export function createToolDefinition(
inputSchema, // AI SDK v6 uses inputSchema inputSchema, // AI SDK v6 uses inputSchema
execute: async (params: Record<string, unknown>) => { execute: async (params: Record<string, unknown>) => {
console.log('[Tool execute] Running:', sanitizedName, params); console.log('[Tool execute] Running:', sanitizedName, params);
if (envVars && Object.keys(envVars).length > 0) {
console.log('[Tool execute] With env vars:', Object.keys(envVars));
}
// Execute the actual npm package using resolved executor // Execute the actual npm package using resolved executor
// Use the actual export name from the Tool record // Use the actual export name from the Tool record
@ -135,6 +140,7 @@ export function createToolDefinition(
packageName: tool.package.npmPackageName, packageName: tool.package.npmPackageName,
name: tool.name, // Use actual export name (e.g., "helloWorldTool", "default") name: tool.name, // Use actual export name (e.g., "helloWorldTool", "default")
params, params,
env: envVars && Object.keys(envVars).length > 0 ? envVars : undefined,
}); });
if (!result.success) { if (!result.success) {

View file

@ -162,7 +162,9 @@ export async function executeWithExecutor(
} }
// Default: use existing package-executor (which uses SANDBOX_EXECUTOR_URL) // Default: use existing package-executor (which uses SANDBOX_EXECUTOR_URL)
const result = await executePackage(request.packageName, request.name, request.params); const result = await executePackage(request.packageName, request.name, request.params, {
env: request.env,
});
return { return {
success: result.success, success: result.success,

View file

@ -426,6 +426,10 @@ model Collection {
executorType String? @map("executor_type") @db.VarChar(50) executorType String? @map("executor_type") @db.VarChar(50)
executorConfig Json? @map("executor_config") @db.JsonB executorConfig Json? @map("executor_config") @db.JsonB
// Tool environment variables (encrypted JSON: { "KEY": "value" })
// These are passed to tools when executed
envVars Json? @map("env_vars") @db.JsonB
// Timestamps // Timestamps
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -522,6 +526,10 @@ model Agent {
executorType String? @map("executor_type") @db.VarChar(50) executorType String? @map("executor_type") @db.VarChar(50)
executorConfig Json? @map("executor_config") @db.JsonB executorConfig Json? @map("executor_config") @db.JsonB
// Tool environment variables (encrypted JSON: { "KEY": "value" })
// Agent env vars override collection env vars, otherwise merged
envVars Json? @map("env_vars") @db.JsonB
// Timestamps // Timestamps
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View file

@ -34,17 +34,23 @@ export async function executePackage(
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = setTimeout(() => controller.abort(), timeout);
// Build request body, only include env if provided
const requestBody: Record<string, unknown> = {
packageName,
name: functionName,
version: 'latest',
params,
};
if (options.env && Object.keys(options.env).length > 0) {
requestBody.env = options.env;
}
const response = await fetch(`${getSandboxUrl()}/execute-tool`, { const response = await fetch(`${getSandboxUrl()}/execute-tool`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify(requestBody),
packageName,
name: functionName,
version: 'latest',
params,
}),
signal: controller.signal, signal: controller.signal,
}); });

View file

@ -18,4 +18,6 @@ export interface PackageInfo {
export interface ExecutorOptions { export interface ExecutorOptions {
timeout?: number; // Milliseconds timeout?: number; // Milliseconds
cacheDir?: string; cacheDir?: string;
/** Environment variables to inject during execution */
env?: Record<string, string>;
} }

View file

@ -0,0 +1,19 @@
# @tpmjs/discord-post
## 0.2.0
### Minor Changes
- Add Discord tools for reading server messages and posting to channels
- `@tpmjs/discord-read`: Read messages from a Discord server for the past N hours
- `@tpmjs/discord-post`: Post messages and rich embeds to a Discord channel
## 0.2.0
### Minor Changes
- Add Discord tools for reading server messages and posting to channels
- `@tpmjs/discord-read`: Read messages from a Discord server for the past N hours
- `@tpmjs/discord-post`: Post messages and rich embeds to a Discord channel

View file

@ -0,0 +1,48 @@
{
"name": "@tpmjs/discord-post",
"version": "0.2.0",
"description": "AI SDK tool to post messages to a Discord channel",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"keywords": [
"tpmjs",
"discord",
"ai",
"communication"
],
"tpmjs": {
"category": "communication",
"frameworks": [
"vercel-ai"
],
"env": [
{
"name": "DISCORD_BOT_TOKEN",
"description": "Discord bot token with Send Messages permission",
"required": true
}
],
"tools": [
{
"name": "discordPostTool",
"description": "Post a message or rich embed to a Discord channel. Supports plain text messages and formatted embeds with titles, descriptions, colors, and fields."
}
]
},
"dependencies": {
"ai": "6.0.23"
},
"devDependencies": {
"@tpmjs/tsconfig": "workspace:*",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public"
},
"license": "MIT"
}

View file

@ -0,0 +1,213 @@
/**
* Discord Post Tool
* Posts messages and embeds to a Discord channel
*/
import { jsonSchema, tool } from 'ai';
// Tool input type
type DiscordPostInput = {
channelId: string;
content?: string;
embed?: {
title?: string;
description?: string;
color?: number;
fields?: Array<{
name: string;
value: string;
inline?: boolean;
}>;
footer?: string;
timestamp?: string;
};
};
const DISCORD_API_BASE = 'https://discord.com/api/v10';
/**
* Discord Post Tool
* Posts a message or rich embed to a Discord channel
*/
export const discordPostTool = tool({
description:
'Post a message or rich embed to a Discord channel. Supports plain text messages and formatted embeds with titles, descriptions, colors, and fields.',
inputSchema: jsonSchema<DiscordPostInput>({
type: 'object',
properties: {
channelId: {
type: 'string',
description: 'Discord channel ID to post to',
},
content: {
type: 'string',
description: 'Plain text message content (max 2000 characters)',
},
embed: {
type: 'object',
description: 'Rich embed object for formatted messages',
properties: {
title: {
type: 'string',
description: 'Embed title (max 256 characters)',
},
description: {
type: 'string',
description: 'Embed description (max 4096 characters)',
},
color: {
type: 'number',
description: 'Embed color as decimal (e.g., 5793266 for Discord blurple)',
},
fields: {
type: 'array',
description: 'Array of field objects',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Field name (max 256 chars)',
},
value: {
type: 'string',
description: 'Field value (max 1024 chars)',
},
inline: { type: 'boolean', description: 'Display inline' },
},
required: ['name', 'value'],
},
},
footer: {
type: 'string',
description: 'Footer text (max 2048 characters)',
},
timestamp: {
type: 'string',
description: 'ISO8601 timestamp to display',
},
},
},
},
required: ['channelId'],
additionalProperties: false,
}),
execute: discordPostExecute,
});
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Discord embed building with validation
async function discordPostExecute(input: DiscordPostInput) {
// Get token from environment - the executor injects this
const token = process.env.DISCORD_BOT_TOKEN;
if (!token) {
return {
success: false,
error: 'DISCORD_BOT_TOKEN environment variable is required',
};
}
// Validate that we have something to post
if (!input.content && !input.embed) {
return {
success: false,
error: 'Either content or embed must be provided',
};
}
// Build the message payload
const payload: {
content?: string;
embeds?: Array<{
title?: string;
description?: string;
color?: number;
fields?: Array<{ name: string; value: string; inline?: boolean }>;
footer?: { text: string };
timestamp?: string;
}>;
} = {};
if (input.content) {
// Truncate to Discord's limit
payload.content = input.content.slice(0, 2000);
}
if (input.embed) {
const embed: {
title?: string;
description?: string;
color?: number;
fields?: Array<{ name: string; value: string; inline?: boolean }>;
footer?: { text: string };
timestamp?: string;
} = {};
if (input.embed.title) {
embed.title = input.embed.title.slice(0, 256);
}
if (input.embed.description) {
embed.description = input.embed.description.slice(0, 4096);
}
if (input.embed.color !== undefined) {
embed.color = input.embed.color;
}
if (input.embed.fields && input.embed.fields.length > 0) {
embed.fields = input.embed.fields.slice(0, 25).map((field) => ({
name: field.name.slice(0, 256),
value: field.value.slice(0, 1024),
inline: field.inline,
}));
}
if (input.embed.footer) {
embed.footer = { text: input.embed.footer.slice(0, 2048) };
}
if (input.embed.timestamp) {
embed.timestamp = input.embed.timestamp;
}
payload.embeds = [embed];
}
try {
const response = await fetch(`${DISCORD_API_BASE}/channels/${input.channelId}/messages`, {
method: 'POST',
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
return {
success: false,
error: `Discord API error (${response.status}): ${errorText}`,
};
}
const message = (await response.json()) as {
id: string;
channel_id: string;
timestamp: string;
};
return {
success: true,
messageId: message.id,
channelId: message.channel_id,
timestamp: message.timestamp,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}

View file

@ -0,0 +1,11 @@
{
"extends": "@tpmjs/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,19 @@
# @tpmjs/discord-read
## 0.2.0
### Minor Changes
- Add Discord tools for reading server messages and posting to channels
- `@tpmjs/discord-read`: Read messages from a Discord server for the past N hours
- `@tpmjs/discord-post`: Post messages and rich embeds to a Discord channel
## 0.2.0
### Minor Changes
- Add Discord tools for reading server messages and posting to channels
- `@tpmjs/discord-read`: Read messages from a Discord server for the past N hours
- `@tpmjs/discord-post`: Post messages and rich embeds to a Discord channel

View file

@ -0,0 +1,48 @@
{
"name": "@tpmjs/discord-read",
"version": "0.2.0",
"description": "AI SDK tool to read messages from a Discord server",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"keywords": [
"tpmjs",
"discord",
"ai",
"communication"
],
"tpmjs": {
"category": "communication",
"frameworks": [
"vercel-ai"
],
"env": [
{
"name": "DISCORD_BOT_TOKEN",
"description": "Discord bot token with MESSAGE_CONTENT intent",
"required": true
}
],
"tools": [
{
"name": "discordReadTool",
"description": "Read messages from a Discord server for the past N hours. Fetches all text channels and their messages, filtering by time and optionally excluding bot messages."
}
]
},
"dependencies": {
"ai": "6.0.23"
},
"devDependencies": {
"@tpmjs/tsconfig": "workspace:*",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public"
},
"license": "MIT"
}

View file

@ -0,0 +1,248 @@
/**
* Discord Read Tool
* Fetches messages from a Discord server for the past N hours
*/
import { jsonSchema, tool } from 'ai';
// Tool input type
type DiscordReadInput = {
guildId: string;
hours?: number;
excludeChannels?: string[];
excludeBots?: boolean;
};
const DISCORD_API_BASE = 'https://discord.com/api/v10';
/**
* Fetch messages from a channel, paginating through results
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Discord API pagination with time filtering
async function fetchChannelMessages(
headers: Record<string, string>,
channelId: string,
cutoffTime: number,
excludeBots: boolean
): Promise<
Array<{
author: string;
content: string;
timestamp: string;
attachments: number;
reactions: number;
}>
> {
const messages: Array<{
author: string;
content: string;
timestamp: string;
attachments: number;
reactions: number;
}> = [];
let lastMessageId: string | undefined;
let hasMore = true;
while (hasMore) {
const url = new URL(`${DISCORD_API_BASE}/channels/${channelId}/messages`);
url.searchParams.set('limit', '100');
if (lastMessageId) {
url.searchParams.set('before', lastMessageId);
}
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
// Channel might not be accessible, skip it
break;
}
const batch = (await response.json()) as Array<{
id: string;
content: string;
author: { id: string; username: string; bot?: boolean };
timestamp: string;
attachments: unknown[];
reactions?: unknown[];
}>;
if (batch.length === 0) {
hasMore = false;
break;
}
for (const msg of batch) {
const msgTime = new Date(msg.timestamp).getTime();
// Stop if we've gone past our time window
if (msgTime < cutoffTime) {
hasMore = false;
break;
}
// Skip bot messages if requested
if (excludeBots && msg.author.bot) {
continue;
}
// Skip empty messages (system messages, etc.)
if (!msg.content && msg.attachments.length === 0) {
continue;
}
messages.push({
author: msg.author.username,
content: msg.content || '[attachment]',
timestamp: msg.timestamp,
attachments: msg.attachments.length,
reactions: msg.reactions?.length || 0,
});
}
lastMessageId = batch[batch.length - 1]?.id;
// Rate limit protection
await new Promise((resolve) => setTimeout(resolve, 100));
}
return messages;
}
/**
* Discord Read Tool
* Reads messages from all text channels in a Discord server for the past N hours
*/
export const discordReadTool = tool({
description:
'Read messages from a Discord server for the past N hours. Fetches all text channels and their messages, filtering by time and optionally excluding bot messages.',
inputSchema: jsonSchema<DiscordReadInput>({
type: 'object',
properties: {
guildId: {
type: 'string',
description: 'Discord server (guild) ID',
},
hours: {
type: 'number',
description: 'Number of hours to look back (default: 24)',
},
excludeChannels: {
type: 'array',
items: { type: 'string' },
description: 'Channel IDs to exclude from reading',
},
excludeBots: {
type: 'boolean',
description: 'Whether to exclude bot messages (default: true)',
},
},
required: ['guildId'],
additionalProperties: false,
}),
execute: discordReadExecute,
});
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multi-step Discord API workflow
async function discordReadExecute(input: DiscordReadInput) {
// Get token from environment - the executor injects this
const token = process.env.DISCORD_BOT_TOKEN;
if (!token) {
return {
success: false,
error: 'DISCORD_BOT_TOKEN environment variable is required',
};
}
const headers = {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json',
};
const hours = input.hours || 24;
const excludeBots = input.excludeBots !== false;
const excludeChannels = input.excludeChannels || [];
const cutoffTime = Date.now() - hours * 60 * 60 * 1000;
try {
// 1. Get guild info
const guildResponse = await fetch(`${DISCORD_API_BASE}/guilds/${input.guildId}`, { headers });
if (!guildResponse.ok) {
const error = await guildResponse.text();
return {
success: false,
error: `Failed to fetch guild: ${error}`,
};
}
const guild = (await guildResponse.json()) as { id: string; name: string };
// 2. Get all channels
const channelsResponse = await fetch(`${DISCORD_API_BASE}/guilds/${input.guildId}/channels`, {
headers,
});
if (!channelsResponse.ok) {
return {
success: false,
error: 'Failed to fetch channels',
};
}
const allChannels = (await channelsResponse.json()) as Array<{
id: string;
name: string;
type: number;
}>;
// Filter to text channels only (type 0 = GUILD_TEXT)
const textChannels = allChannels.filter((c) => c.type === 0 && !excludeChannels.includes(c.id));
// 3. Fetch messages from each channel
const channelResults: Array<{
id: string;
name: string;
messageCount: number;
messages: Array<{
author: string;
content: string;
timestamp: string;
attachments: number;
reactions: number;
}>;
}> = [];
for (const channel of textChannels) {
const messages = await fetchChannelMessages(headers, channel.id, cutoffTime, excludeBots);
if (messages.length > 0) {
channelResults.push({
id: channel.id,
name: channel.name,
messageCount: messages.length,
messages,
});
}
}
const totalMessages = channelResults.reduce((sum, c) => sum + c.messageCount, 0);
return {
success: true,
guildName: guild.name,
channels: channelResults,
totalMessages,
timeRange: {
start: new Date(cutoffTime).toISOString(),
end: new Date().toISOString(),
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}

View file

@ -0,0 +1,11 @@
{
"extends": "@tpmjs/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -63,6 +63,8 @@ export const UpdateAgentSchema = z.object({
// Executor configuration // Executor configuration
executorType: ExecutorTypeSchema.nullable().optional(), executorType: ExecutorTypeSchema.nullable().optional(),
executorConfig: ExecutorConfigUpdateSchema, executorConfig: ExecutorConfigUpdateSchema,
// Tool environment variables
envVars: z.record(z.string(), z.string()).nullable().optional(),
}); });
export const AddCollectionToAgentSchema = z.object({ export const AddCollectionToAgentSchema = z.object({

View file

@ -44,6 +44,8 @@ export const UpdateCollectionSchema = z.object({
// Executor configuration // Executor configuration
executorType: ExecutorTypeSchema.nullable().optional(), executorType: ExecutorTypeSchema.nullable().optional(),
executorConfig: ExecutorConfigUpdateSchema, executorConfig: ExecutorConfigUpdateSchema,
// Tool environment variables
envVars: z.record(z.string(), z.string()).nullable().optional(),
}); });
// ============================================================================ // ============================================================================

26
pnpm-lock.yaml generated
View file

@ -716,6 +716,32 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
packages/tools/discord-post:
dependencies:
ai:
specifier: 6.0.23
version: 6.0.23(zod@4.3.5)
devDependencies:
'@tpmjs/tsconfig':
specifier: workspace:*
version: link:../../config/tsconfig
typescript:
specifier: ^5.9.3
version: 5.9.3
packages/tools/discord-read:
dependencies:
ai:
specifier: 6.0.23
version: 6.0.23(zod@4.3.5)
devDependencies:
'@tpmjs/tsconfig':
specifier: workspace:*
version: link:../../config/tsconfig
typescript:
specifier: ^5.9.3
version: 5.9.3
packages/tools/emoji-magic: packages/tools/emoji-magic:
dependencies: dependencies:
ai: ai:

View file

@ -60,6 +60,10 @@
{ {
"path": "/api/sync/cleanup-activity", "path": "/api/sync/cleanup-activity",
"schedule": "0 3 * * *" "schedule": "0 3 * * *"
},
{
"path": "/api/cron/discord-summary",
"schedule": "0 9 * * *"
} }
] ]
} }