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

View file

@ -426,6 +426,10 @@ model Collection {
executorType String? @map("executor_type") @db.VarChar(50)
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
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -522,6 +526,10 @@ model Agent {
executorType String? @map("executor_type") @db.VarChar(50)
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
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View file

@ -34,17 +34,23 @@ export async function executePackage(
const controller = new AbortController();
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`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
packageName,
name: functionName,
version: 'latest',
params,
}),
body: JSON.stringify(requestBody),
signal: controller.signal,
});

View file

@ -18,4 +18,6 @@ export interface PackageInfo {
export interface ExecutorOptions {
timeout?: number; // Milliseconds
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
executorType: ExecutorTypeSchema.nullable().optional(),
executorConfig: ExecutorConfigUpdateSchema,
// Tool environment variables
envVars: z.record(z.string(), z.string()).nullable().optional(),
});
export const AddCollectionToAgentSchema = z.object({

View file

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