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
+[](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
+
+[](https://railway.com/deploy/lettabot)
+
+Or add to your README:
+
+```markdown
+[](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;
+ }
+}