Files
lettabot/src/config/types.ts

684 lines
27 KiB
TypeScript

/**
* LettaBot Configuration Types
*
* Two modes:
* 1. Docker server: Uses baseUrl (e.g., http://localhost:8283), no API key
* 2. Letta API: Uses apiKey, optional BYOK providers
*/
import { createLogger } from '../logger.js';
const log = createLogger('Config');
export type ServerMode = 'api' | 'docker' | 'cloud' | 'selfhosted';
export type CanonicalServerMode = 'api' | 'docker';
export function canonicalizeServerMode(mode?: ServerMode): CanonicalServerMode {
return mode === 'docker' || mode === 'selfhosted' ? 'docker' : 'api';
}
export function isDockerServerMode(mode?: ServerMode): boolean {
return canonicalizeServerMode(mode) === 'docker';
}
export function isApiServerMode(mode?: ServerMode): boolean {
return canonicalizeServerMode(mode) === 'api';
}
export function serverModeLabel(mode?: ServerMode): string {
return canonicalizeServerMode(mode);
}
/**
* Display configuration for tool calls and reasoning in channel output.
*/
export interface DisplayConfig {
/** Show tool invocations in channel output (default: false) */
showToolCalls?: boolean;
/** Show agent reasoning/thinking in channel output (default: false) */
showReasoning?: boolean;
/** Truncate reasoning to N characters (default: 0 = no limit) */
reasoningMaxChars?: number;
}
/**
* Configuration for a single agent in multi-agent mode.
* Each agent has its own name, channels, and features.
*/
export interface AgentConfig {
/** Agent name (used for display, agent creation, and store keying) */
name: string;
/** Use existing agent ID (skip creation) */
id?: string;
/** Display name prefixed to outbound messages (e.g. "💜 Signo") */
displayName?: string;
/** Model for initial agent creation */
model?: string;
/** Working directory for this agent's SDK sessions (overrides global) */
workingDir?: string;
/** Channels this agent connects to */
channels: {
telegram?: TelegramConfig;
'telegram-mtproto'?: TelegramMTProtoConfig;
slack?: SlackConfig;
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
discord?: DiscordConfig;
};
/** Conversation routing */
conversations?: {
mode?: 'shared' | 'per-channel' | 'per-chat'; // Default: shared (single conversation across all channels)
heartbeat?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
perChannel?: string[]; // Channels that should always have their own conversation
maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)
};
/** Features for this agent */
features?: {
cron?: boolean;
heartbeat?: {
enabled: boolean;
intervalMin?: number;
skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
prompt?: string; // Custom heartbeat prompt (replaces default body)
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
};
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
maxToolCalls?: number;
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete after send (default: false)
display?: DisplayConfig;
allowedTools?: string[]; // Per-agent tool whitelist (overrides global/env ALLOWED_TOOLS)
disallowedTools?: string[]; // Per-agent tool blocklist (overrides global/env DISALLOWED_TOOLS)
};
/** Security settings */
security?: {
redaction?: {
secrets?: boolean;
pii?: boolean;
};
};
/** Polling config */
polling?: PollingYamlConfig;
/** Integrations */
integrations?: {
google?: GoogleConfig;
};
}
export interface LettaBotConfig {
// Server connection
server: {
// Canonical values: 'api' or 'docker'
// Legacy aliases accepted for compatibility: 'cloud', 'selfhosted'
mode: ServerMode;
// Only for docker mode
baseUrl?: string;
// Only for api mode
apiKey?: string;
// Log level (fatal|error|warn|info|debug|trace). Env vars LOG_LEVEL / LETTABOT_LOG_LEVEL override.
logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
// API server config (port, host, CORS) — canonical location
api?: {
port?: number; // Default: 8080 (or PORT env var)
host?: string; // Default: 127.0.0.1 (secure). Use '0.0.0.0' for Docker/Railway
corsOrigin?: string; // CORS origin. Default: same-origin only
};
};
// Multi-agent configuration
agents?: AgentConfig[];
// Agent configuration
agent: {
id?: string;
name: string;
displayName?: string;
// model is configured on the Letta agent server-side, not in config
// Kept as optional for backward compat (ignored if present in existing configs)
model?: string;
};
// BYOK providers (api mode only)
providers?: ProviderConfig[];
// Channel configurations
channels: {
telegram?: TelegramConfig;
'telegram-mtproto'?: TelegramMTProtoConfig;
slack?: SlackConfig;
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
discord?: DiscordConfig;
};
// Conversation routing
conversations?: {
mode?: 'shared' | 'per-channel' | 'per-chat'; // Default: shared (single conversation across all channels)
heartbeat?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
perChannel?: string[]; // Channels that should always have their own conversation
maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)
};
// Features
features?: {
cron?: boolean;
heartbeat?: {
enabled: boolean;
intervalMin?: number;
skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
prompt?: string; // Custom heartbeat prompt (replaces default body)
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
};
inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths.
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete after send (default: false)
display?: DisplayConfig; // Show tool calls / reasoning in channel output
allowedTools?: string[]; // Global tool whitelist (overridden by per-agent, falls back to ALLOWED_TOOLS env)
disallowedTools?: string[]; // Global tool blocklist (overridden by per-agent, falls back to DISALLOWED_TOOLS env)
};
// Polling - system-level background checks (Gmail, etc.)
polling?: PollingYamlConfig;
// Integrations (Google Workspace, etc.)
// NOTE: integrations.google is a legacy path for polling config.
// Prefer the top-level `polling` section instead.
integrations?: {
google?: GoogleConfig;
};
// Transcription (inbound voice messages)
transcription?: TranscriptionConfig;
// Text-to-speech (outbound voice memos)
tts?: TtsConfig;
// Attachment handling
attachments?: {
maxMB?: number;
maxAgeDays?: number;
};
// Security
security?: {
/** Outbound message redaction (catches leaked secrets/PII before channel delivery) */
redaction?: {
/** Redact common secret patterns (API keys, tokens, bearer tokens). Default: true */
secrets?: boolean;
/** Redact PII patterns (emails, phone numbers). Default: false */
pii?: boolean;
};
};
// API server (health checks, CLI messaging)
/** @deprecated Use server.api instead */
api?: {
port?: number; // Default: 8080 (or PORT env var)
host?: string; // Default: 127.0.0.1 (secure). Use '0.0.0.0' for Docker/Railway
corsOrigin?: string; // CORS origin. Default: same-origin only
};
}
export interface TtsConfig {
provider?: 'elevenlabs' | 'openai'; // Default: 'elevenlabs'
apiKey?: string; // Falls back to ELEVENLABS_API_KEY or OPENAI_API_KEY env var
voiceId?: string; // ElevenLabs voice ID or OpenAI voice name
model?: string; // Model ID (provider-specific defaults)
}
export interface TranscriptionConfig {
provider: 'openai' | 'mistral';
apiKey?: string; // Falls back to OPENAI_API_KEY or MISTRAL_API_KEY env var
model?: string; // Defaults to 'whisper-1' (OpenAI) or 'voxtral-mini-latest' (Mistral)
}
export interface PollingYamlConfig {
enabled?: boolean; // Master switch (default: auto-detected from sub-configs)
intervalMs?: number; // Polling interval in milliseconds (default: 60000)
gmail?: {
enabled?: boolean; // Enable Gmail polling
account?: string; // Gmail account to poll (e.g., user@example.com)
accounts?: string[]; // Multiple Gmail accounts to poll
};
}
export interface ProviderConfig {
id: string; // e.g., 'anthropic', 'openai'
name: string; // e.g., 'lc-anthropic'
type: string; // e.g., 'anthropic', 'openai'
apiKey: string;
}
export type GroupMode = 'open' | 'listen' | 'mention-only' | 'disabled';
export interface GroupConfig {
mode?: GroupMode;
/** Only process group messages from these user IDs. Omit to allow all users. */
allowedUsers?: string[];
/** Process messages from other bots instead of dropping them. Default: false. */
receiveBotMessages?: boolean;
/**
* @deprecated Use mode: "mention-only" (true) or "open" (false).
*/
requireMention?: boolean;
}
export interface TelegramConfig {
enabled: boolean;
token?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
streaming?: boolean; // Stream responses via progressive message edits (default: false)
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group chat IDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@mybot"])
groups?: Record<string, GroupConfig>; // Per-group settings, "*" for defaults
}
export interface TelegramMTProtoConfig {
enabled: boolean;
phoneNumber?: string; // E.164 format: +1234567890
apiId?: number; // From my.telegram.org
apiHash?: string; // From my.telegram.org
databaseDirectory?: string; // Default: ./data/telegram-mtproto
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: number[]; // Telegram user IDs
groupPolicy?: 'mention' | 'reply' | 'both' | 'off';
adminChatId?: number; // Chat ID for pairing request notifications
}
export interface SlackConfig {
enabled: boolean;
appToken?: string;
botToken?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
streaming?: boolean; // Stream responses via progressive message edits (default: false)
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Channel IDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
groups?: Record<string, GroupConfig>; // Per-channel settings, "*" for defaults
}
export interface WhatsAppConfig {
enabled: boolean;
sessionPath?: string; // Auth/session directory (default: ./data/whatsapp-session)
selfChat?: boolean;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupPolicy?: 'open' | 'disabled' | 'allowlist';
groupAllowFrom?: string[];
mentionPatterns?: string[];
groups?: Record<string, GroupConfig>;
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group JIDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
}
export interface SignalConfig {
enabled: boolean;
phone?: string;
cliPath?: string; // Path to signal-cli binary (default: "signal-cli")
httpHost?: string; // Daemon HTTP host (default: "127.0.0.1")
httpPort?: number; // Daemon HTTP port (default: 8090)
selfChat?: boolean;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
// Group gating
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@bot"])
groups?: Record<string, GroupConfig>; // Per-group settings, "*" for defaults
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Group IDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
}
export interface DiscordConfig {
enabled: boolean;
token?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
streaming?: boolean; // Stream responses via progressive message edits (default: false)
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
groups?: Record<string, GroupConfig>; // Per-guild/channel settings, "*" for defaults
}
/**
* Telegram MTProto (user account) configuration.
* Uses TDLib for user account mode instead of Bot API.
* Cannot be used simultaneously with TelegramConfig (bot mode).
*/
export interface TelegramMTProtoConfig {
enabled: boolean;
phoneNumber?: string; // E.164 format: +1234567890
apiId?: number; // From my.telegram.org
apiHash?: string; // From my.telegram.org
databaseDirectory?: string; // Default: ./data/telegram-mtproto
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: number[]; // Telegram user IDs
groupPolicy?: 'mention' | 'reply' | 'both' | 'off';
adminChatId?: number; // Chat ID for pairing request notifications
groupDebounceSec?: number; // Debounce interval in seconds (default: 5, 0 = immediate)
instantGroups?: string[]; // Chat IDs that bypass batching
}
export interface GoogleAccountConfig {
account: string;
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
}
export interface GoogleConfig {
enabled: boolean;
account?: string;
accounts?: GoogleAccountConfig[];
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
pollIntervalSec?: number; // Polling interval in seconds (default: 60)
}
// Default config
export const DEFAULT_CONFIG: LettaBotConfig = {
server: {
mode: 'api',
},
agent: {
name: 'LettaBot',
// model is configured on the Letta agent server-side (via onboarding or `lettabot model set`)
},
channels: {},
};
type ChannelWithLegacyGroupFields = {
groups?: Record<string, GroupConfig>;
listeningGroups?: string[];
};
const warnedGroupConfigDeprecations = new Set<string>();
function warnGroupConfigDeprecation(path: string, detail: string): void {
const key = `${path}:${detail}`;
if (warnedGroupConfigDeprecations.has(key)) return;
warnedGroupConfigDeprecations.add(key);
log.warn(`WARNING: ${path} ${detail}`);
}
function normalizeLegacyGroupFields(
channel: ChannelWithLegacyGroupFields | undefined,
path: string,
): void {
if (!channel) return;
const hadOriginalGroups = !!(
channel.groups &&
typeof channel.groups === 'object' &&
Object.keys(channel.groups).length > 0
);
const groups: Record<string, GroupConfig> = channel.groups && typeof channel.groups === 'object'
? { ...channel.groups }
: {};
const modeDerivedFromRequireMention = new Set<string>();
let sawLegacyRequireMention = false;
for (const [groupId, value] of Object.entries(groups)) {
const group = value && typeof value === 'object' ? { ...value } : {};
const hasLegacyRequireMention = typeof group.requireMention === 'boolean';
if (hasLegacyRequireMention) {
sawLegacyRequireMention = true;
}
if (!group.mode && hasLegacyRequireMention) {
group.mode = group.requireMention ? 'mention-only' : 'open';
modeDerivedFromRequireMention.add(groupId);
}
if ('requireMention' in group) {
delete group.requireMention;
}
groups[groupId] = group;
}
if (sawLegacyRequireMention) {
warnGroupConfigDeprecation(
`${path}.groups.<id>.requireMention`,
'is deprecated. Use groups.<id>.mode: "mention-only" | "open" | "listen".'
);
}
const legacyListeningGroups = Array.isArray(channel.listeningGroups)
? channel.listeningGroups.map((id) => String(id).trim()).filter(Boolean)
: [];
if (legacyListeningGroups.length > 0) {
warnGroupConfigDeprecation(
`${path}.listeningGroups`,
'is deprecated. Use groups.<id>.mode: "listen".'
);
for (const id of legacyListeningGroups) {
const existing = groups[id] ? { ...groups[id] } : {};
if (!existing.mode || modeDerivedFromRequireMention.has(id)) {
existing.mode = 'listen';
} else if (existing.mode !== 'listen') {
warnGroupConfigDeprecation(
`${path}.groups.${id}.mode`,
`is "${existing.mode}" while ${path}.listeningGroups also includes "${id}". Keeping mode "${existing.mode}".`
);
}
groups[id] = existing;
}
// Legacy listeningGroups never restricted other groups.
// Add wildcard open when there was no explicit groups config.
if (!hadOriginalGroups && !groups['*']) {
groups['*'] = { mode: 'open' };
}
}
channel.groups = Object.keys(groups).length > 0 ? groups : undefined;
delete channel.listeningGroups;
}
/**
* Normalize config to multi-agent format.
*
* If the config uses legacy single-agent format (agent: + channels:),
* it's converted to an agents[] array with one entry.
* Channels with `enabled: false` are dropped during normalization.
*/
export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
const normalizeChannels = (channels?: AgentConfig['channels'], sourcePath = 'channels'): AgentConfig['channels'] => {
const normalized: AgentConfig['channels'] = {};
if (!channels) return normalized;
// Merge env vars into YAML blocks that are missing their key credential.
// Without this, `signal: enabled: true` + SIGNAL_PHONE_NUMBER env var
// silently fails because the env-var-only fallback (below) only fires
// when the YAML block is completely absent.
if (channels.telegram && !channels.telegram.token && process.env.TELEGRAM_BOT_TOKEN) {
channels.telegram.token = process.env.TELEGRAM_BOT_TOKEN;
}
if (channels.slack) {
if (!channels.slack.botToken && process.env.SLACK_BOT_TOKEN) channels.slack.botToken = process.env.SLACK_BOT_TOKEN;
if (!channels.slack.appToken && process.env.SLACK_APP_TOKEN) channels.slack.appToken = process.env.SLACK_APP_TOKEN;
}
if (channels.signal && !channels.signal.phone && process.env.SIGNAL_PHONE_NUMBER) {
channels.signal.phone = process.env.SIGNAL_PHONE_NUMBER;
}
if (channels.discord && !channels.discord.token && process.env.DISCORD_BOT_TOKEN) {
channels.discord.token = process.env.DISCORD_BOT_TOKEN;
}
if (channels.telegram?.enabled !== false && channels.telegram?.token) {
const telegram = { ...channels.telegram };
normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`);
normalized.telegram = telegram;
}
// telegram-mtproto: check apiId as the key credential
if (channels['telegram-mtproto']?.enabled !== false && channels['telegram-mtproto']?.apiId) {
normalized['telegram-mtproto'] = channels['telegram-mtproto'];
}
if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) {
const slack = { ...channels.slack };
normalizeLegacyGroupFields(slack, `${sourcePath}.slack`);
normalized.slack = slack;
}
// WhatsApp has no credential to check (uses QR pairing), so just check enabled
if (channels.whatsapp?.enabled) {
const whatsapp = { ...channels.whatsapp };
normalizeLegacyGroupFields(whatsapp, `${sourcePath}.whatsapp`);
normalized.whatsapp = whatsapp;
}
if (channels.signal?.enabled !== false && channels.signal?.phone) {
const signal = { ...channels.signal };
normalizeLegacyGroupFields(signal, `${sourcePath}.signal`);
normalized.signal = signal;
}
if (channels.discord?.enabled !== false && channels.discord?.token) {
const discord = { ...channels.discord };
normalizeLegacyGroupFields(discord, `${sourcePath}.discord`);
normalized.discord = discord;
}
// Warn when a channel block exists but was dropped due to missing credentials
const channelCredentials: Array<[string, unknown, boolean]> = [
['telegram', channels.telegram, !!normalized.telegram],
['slack', channels.slack, !!normalized.slack],
['signal', channels.signal, !!normalized.signal],
['discord', channels.discord, !!normalized.discord],
];
for (const [name, raw, included] of channelCredentials) {
if (raw && (raw as Record<string, unknown>).enabled !== false && !included) {
log.warn(`Channel '${name}' is in ${sourcePath} but missing required credentials -- skipping. Check your lettabot.yaml or environment variables.`);
}
}
return normalized;
};
// Multi-agent mode: normalize channels for each configured agent
if (config.agents && config.agents.length > 0) {
return config.agents.map((agent, index) => ({
...agent,
channels: normalizeChannels(agent.channels, `agents[${index}].channels`),
}));
}
// Legacy single-agent mode: normalize to agents[]
const envAgentName = process.env.LETTA_AGENT_NAME || process.env.AGENT_NAME;
const agentName = envAgentName || config.agent?.name || 'LettaBot';
const model = config.agent?.model;
const id = config.agent?.id;
// Filter out disabled/misconfigured channels
const channels = normalizeChannels(config.channels, 'channels');
// Env var fallback for container deploys without lettabot.yaml (e.g. Railway)
// Helper: parse comma-separated env var into string array (or undefined)
const parseList = (envVar?: string): string[] | undefined =>
envVar ? envVar.split(',').map(s => s.trim()).filter(Boolean) : undefined;
if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) {
channels.telegram = {
enabled: true,
token: process.env.TELEGRAM_BOT_TOKEN,
dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS),
};
}
// telegram-mtproto env var fallback (only if telegram bot not configured)
if (!channels.telegram && !channels['telegram-mtproto'] && process.env.TELEGRAM_API_ID && process.env.TELEGRAM_API_HASH && process.env.TELEGRAM_PHONE_NUMBER) {
channels['telegram-mtproto'] = {
enabled: true,
apiId: parseInt(process.env.TELEGRAM_API_ID, 10),
apiHash: process.env.TELEGRAM_API_HASH,
phoneNumber: process.env.TELEGRAM_PHONE_NUMBER,
databaseDirectory: process.env.TELEGRAM_MTPROTO_DB_DIR || './data/telegram-mtproto',
dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS)?.map(s => parseInt(s, 10)).filter(n => !isNaN(n)),
groupPolicy: (process.env.TELEGRAM_GROUP_POLICY as 'mention' | 'reply' | 'both' | 'off') || 'both',
adminChatId: process.env.TELEGRAM_ADMIN_CHAT_ID ? parseInt(process.env.TELEGRAM_ADMIN_CHAT_ID, 10) : undefined,
};
}
if (!channels.slack && process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
channels.slack = {
enabled: true,
botToken: process.env.SLACK_BOT_TOKEN,
appToken: process.env.SLACK_APP_TOKEN,
dmPolicy: (process.env.SLACK_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.SLACK_ALLOWED_USERS),
};
}
if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') {
channels.whatsapp = {
enabled: true,
selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false',
dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS),
};
}
if (!channels.signal && process.env.SIGNAL_PHONE_NUMBER) {
channels.signal = {
enabled: true,
phone: process.env.SIGNAL_PHONE_NUMBER,
selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false',
dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS),
};
}
if (!channels.discord && process.env.DISCORD_BOT_TOKEN) {
channels.discord = {
enabled: true,
token: process.env.DISCORD_BOT_TOKEN,
dmPolicy: (process.env.DISCORD_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.DISCORD_ALLOWED_USERS),
};
}
// Field-level env var fallback for features (heartbeat, cron).
// Unlike channels (all-or-nothing), features are independent toggles so we
// merge at the field level: env vars fill in fields missing from YAML.
const features = { ...config.features } as NonNullable<LettaBotConfig['features']>;
if (features.cron == null && process.env.CRON_ENABLED === 'true') {
features.cron = true;
}
if (!features.heartbeat && process.env.HEARTBEAT_ENABLED === 'true') {
const intervalMin = process.env.HEARTBEAT_INTERVAL_MIN
? parseInt(process.env.HEARTBEAT_INTERVAL_MIN, 10)
: undefined;
const skipRecentUserMin = process.env.HEARTBEAT_SKIP_RECENT_USER_MIN
? parseInt(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN, 10)
: undefined;
features.heartbeat = {
enabled: true,
...(Number.isFinite(intervalMin) ? { intervalMin } : {}),
...(Number.isFinite(skipRecentUserMin) ? { skipRecentUserMin } : {}),
};
}
// Only pass features if there's actually something set
const hasFeatures = Object.keys(features).length > 0;
return [{
name: agentName,
id,
displayName: config.agent?.displayName,
model,
channels,
conversations: config.conversations,
features: hasFeatures ? features : config.features,
polling: config.polling,
integrations: config.integrations,
}];
}