From 75f65049bc385e005da00921d04a8382d031d574 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 3 Feb 2026 14:46:37 -0800 Subject: [PATCH] feat: Railway deployment support with one-click deploy (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Railway deployment support with agent auto-discovery - Add railway.toml for build/deploy config with health checks - Skip config file requirement when RAILWAY_ENVIRONMENT detected - Auto-discover existing agent by name on container deploys - Add findAgentByName() API function for agent lookup - Add setAgentId() method to LettaBot class - Add comprehensive Railway deployment docs One-click deploy flow: 1. Set LETTA_API_KEY + channel tokens 2. LettaBot finds existing agent by AGENT_NAME (default: "LettaBot") 3. If not found, creates on first message 4. Subsequent deploys auto-reconnect to same agent Written by Cameron ◯ Letta Code "The best way to predict the future is to deploy it." - Railway, probably * fix: specify Node 22 for Railway deployment * fix: fail fast if LETTA_API_KEY is missing * fix: don't await Telegram bot.start() - it never resolves * fix: extract message from send_message tool call * Revert "fix: extract message from send_message tool call" This reverts commit 370306e49de3728434352d2df1b78c744e888833. * fix: clear LETTA_AGENT_ID env var when agent doesn't exist * docs: add Railway deploy button to README and docs * fix: .nvmrc newline and correct MODEL default in docs --- .nvmrc | 1 + README.md | 2 + docs/railway-deploy.md | 111 +++++++++++++++++++++++++++++++++++++++ railway.toml | 20 +++++++ src/channels/telegram.ts | 7 ++- src/core/bot.ts | 7 +++ src/main.ts | 40 +++++++++++--- src/tools/letta-api.ts | 27 ++++++++++ 8 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 .nvmrc create mode 100644 docs/railway-deploy.md create mode 100644 railway.toml diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 7297fcd..05ec5b5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Your personal AI assistant that remembers everything across **Telegram, Slack, D lettabot-preview +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.com/deploy/lettabot) + ## Features - **Multi-Channel** - Chat seamlessly across Telegram, Slack, Discord, WhatsApp, and Signal diff --git a/docs/railway-deploy.md b/docs/railway-deploy.md new file mode 100644 index 0000000..703c039 --- /dev/null +++ b/docs/railway-deploy.md @@ -0,0 +1,111 @@ +# Railway Deployment + +Deploy LettaBot to [Railway](https://railway.app) for always-on hosting. + +## One-Click Deploy + +1. Fork this repository +2. Connect to Railway +3. Set environment variables (see below) +4. Deploy! + +**No local setup required.** LettaBot automatically finds or creates your agent by name. + +## Environment Variables + +### Required + +| Variable | Description | +|----------|-------------| +| `LETTA_API_KEY` | Your Letta Cloud API key ([get one here](https://app.letta.com)) | + +### Channel Configuration (at least one required) + +**Telegram:** +``` +TELEGRAM_BOT_TOKEN=your-bot-token +TELEGRAM_DM_POLICY=pairing +``` + +**Discord:** +``` +DISCORD_BOT_TOKEN=your-bot-token +DISCORD_DM_POLICY=pairing +``` + +**Slack:** +``` +SLACK_BOT_TOKEN=xoxb-... +SLACK_APP_TOKEN=xapp-... +``` + +### Optional + +| Variable | Default | Description | +|----------|---------|-------------| +| `AGENT_NAME` | `LettaBot` | Agent name (used to find/create agent) | +| `MODEL` | `zai/glm-4.7` | Model for new agents (ignored for existing agents) | +| `LETTA_AGENT_ID` | - | Override auto-discovery with specific agent ID | +| `CRON_ENABLED` | `false` | Enable cron jobs | +| `HEARTBEAT_INTERVAL_MIN` | - | Enable heartbeats (minutes) | +| `HEARTBEAT_TARGET` | - | Target chat (e.g., `telegram:123456`) | +| `OPENAI_API_KEY` | - | For voice message transcription | + +## How It Works + +### Agent Discovery + +On startup, LettaBot: +1. Checks for `LETTA_AGENT_ID` env var - uses if set +2. Otherwise, searches Letta Cloud for an agent named `AGENT_NAME` (default: "LettaBot") +3. If found, uses the existing agent (preserves memory!) +4. If not found, creates a new agent on first message + +This means **your agent persists across deploys** without any manual ID copying. + +### Build & Deploy + +Railway automatically: +- Detects Node.js and installs dependencies +- Runs `npm run build` to compile TypeScript +- Runs `npm start` to start the server +- Sets the `PORT` environment variable +- Monitors `/health` endpoint + +## Channel Limitations + +| Channel | Railway Support | Notes | +|---------|-----------------|-------| +| Telegram | Yes | Full support | +| Discord | Yes | Full support | +| Slack | Yes | Full support | +| WhatsApp | No | Requires local QR pairing | +| Signal | No | Requires local device registration | + +## Troubleshooting + +### "No channels configured" + +Set at least one channel token (TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, or SLACK tokens). + +### Agent not found / wrong agent + +- Check `AGENT_NAME` matches your intended agent +- Or set `LETTA_AGENT_ID` explicitly to use a specific agent +- Multiple agents with the same name? The most recently created one is used + +### Health check failing + +Check Railway logs for startup errors. Common issues: +- Missing `LETTA_API_KEY` +- Invalid channel tokens + +## Deploy Button + +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.com/deploy/lettabot) + +Or add to your README: + +```markdown +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.com/deploy/lettabot) +``` diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..6d03734 --- /dev/null +++ b/railway.toml @@ -0,0 +1,20 @@ +# Railway deployment configuration +# https://docs.railway.app/reference/config-as-code + +[build] +builder = "NIXPACKS" + +[build.nixpacks] +nixPkgs = ["nodejs_22"] + +[deploy] +# Build TypeScript before starting +buildCommand = "npm run build" +# Start the server +startCommand = "npm start" +# Health check endpoint +healthcheckPath = "/health" +healthcheckTimeout = 30 +# Restart policy +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index f481367..87554ca 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -258,13 +258,18 @@ export class TelegramAdapter implements ChannelAdapter { async start(): Promise { if (this.running) return; - await this.bot.start({ + // Don't await - bot.start() never resolves (it's a long-polling loop) + // The onStart callback fires when polling begins + this.bot.start({ onStart: (botInfo) => { console.log(`[Telegram] Bot started as @${botInfo.username}`); console.log(`[Telegram] DM policy: ${this.config.dmPolicy}`); this.running = true; }, }); + + // Give it a moment to connect before returning + await new Promise(resolve => setTimeout(resolve, 1000)); } async stop(): Promise { diff --git a/src/core/bot.ts b/src/core/bot.ts index 12f213a..528f275 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -520,6 +520,13 @@ export class LettaBot { }; } + /** + * Set agent ID (for container deploys that discover existing agents) + */ + setAgentId(agentId: string): void { + this.store.agentId = agentId; + console.log(`[Bot] Agent ID set to: ${agentId}`); + } /** * Reset agent (clear memory) diff --git a/src/main.ts b/src/main.ts index 6f8e8dc..5a23246 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,7 +14,8 @@ import { spawn } from 'node:child_process'; import { loadConfig, applyConfigToEnv, syncProviders, resolveConfigPath } from './config/index.js'; import { isLettaCloudUrl } from './utils/server.js'; const yamlConfig = loadConfig(); -console.log(`[Config] Loaded from ${resolveConfigPath()}`); +const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables'; +console.log(`[Config] Loaded from ${configSource}`); console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}, Model: ${yamlConfig.agent.model}`); applyConfigToEnv(yamlConfig); @@ -117,12 +118,13 @@ import { DiscordAdapter } from './channels/discord.js'; import { CronService } from './cron/service.js'; import { HeartbeatService } from './cron/heartbeat.js'; import { PollingService } from './polling/service.js'; -import { agentExists } from './tools/letta-api.js'; +import { agentExists, findAgentByName } from './tools/letta-api.js'; import { installSkillsToWorkingDir } from './skills/loader.js'; -// Check if config exists +// Check if config exists (skip in Railway/Docker where env vars are used directly) const configPath = resolveConfigPath(); -if (!existsSync(configPath)) { +const isContainerDeploy = !!(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.FLY_APP_NAME || process.env.DOCKER_DEPLOY); +if (!existsSync(configPath) && !isContainerDeploy) { console.log(`\n No config found at ${configPath}. Run "lettabot onboard" first.\n`); process.exit(1); } @@ -289,6 +291,13 @@ if (!config.telegram.enabled && !config.slack.enabled && !config.whatsapp.enable process.exit(1); } +// Validate LETTA_API_KEY is set for cloud mode +if (!process.env.LETTA_API_KEY) { + console.error('\n Error: LETTA_API_KEY is required.'); + console.error(' Get your API key from https://app.letta.com and set it as an environment variable.\n'); + process.exit(1); +} + async function main() { console.log('Starting LettaBot...\n'); @@ -333,15 +342,31 @@ async function main() { if (initialStatus.agentId) { const exists = await agentExists(initialStatus.agentId); if (!exists) { - console.log(`[Agent] Stored agent ${initialStatus.agentId} not found - creating new agent...`); + console.log(`[Agent] Stored agent ${initialStatus.agentId} not found on server`); bot.reset(); + // Also clear env var so search-by-name can run + delete process.env.LETTA_AGENT_ID; + initialStatus = bot.getStatus(); + } + } + + // Container deploy: try to find existing agent by name if no ID set + const agentName = process.env.AGENT_NAME || 'LettaBot'; + if (!initialStatus.agentId && isContainerDeploy) { + console.log(`[Agent] Searching for existing agent named "${agentName}"...`); + const found = await findAgentByName(agentName); + if (found) { + console.log(`[Agent] Found existing agent: ${found.id}`); + process.env.LETTA_AGENT_ID = found.id; + // Reinitialize bot with found agent + bot.setAgentId(found.id); initialStatus = bot.getStatus(); } } // Agent will be created on first user message (lazy initialization) if (!initialStatus.agentId) { - console.log('[Agent] No agent found - will create on first message'); + console.log(`[Agent] No agent found - will create "${agentName}" on first message`); } // Register enabled channels @@ -475,6 +500,9 @@ async function main() { console.log('LettaBot is running!'); console.log('================================='); console.log(`Agent ID: ${status.agentId || '(will be created on first message)'}`); + if (isContainerDeploy && status.agentId) { + console.log(`[Agent] Using agent "${agentName}" (auto-discovered by name)`); + } console.log(`Channels: ${status.channels.join(', ')}`); console.log(`Cron: ${config.cronEnabled ? 'enabled' : 'disabled'}`); console.log(`Heartbeat: ${config.heartbeat.enabled ? `every ${config.heartbeat.intervalMinutes} min` : 'disabled'}`); diff --git a/src/tools/letta-api.ts b/src/tools/letta-api.ts index 9099454..b1d3edd 100644 --- a/src/tools/letta-api.ts +++ b/src/tools/letta-api.ts @@ -184,3 +184,30 @@ export async function listAgents(query?: string): Promise { + try { + const client = getClient(); + const page = await client.agents.list({ query_text: name, limit: 50 }); + let bestMatch: { id: string; name: string; created_at?: string | null } | null = null; + + for await (const agent of page) { + // Exact name match only + if (agent.name === name) { + // Keep the most recently created if multiple match + if (!bestMatch || (agent.created_at && bestMatch.created_at && agent.created_at > bestMatch.created_at)) { + bestMatch = { id: agent.id, name: agent.name, created_at: agent.created_at }; + } + } + } + + return bestMatch ? { id: bestMatch.id, name: bestMatch.name } : null; + } catch (e) { + console.error('[Letta API] Failed to find agent by name:', e); + return null; + } +}