feat: Railway deployment support with one-click deploy (#106)
* 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
This commit is contained in:
@@ -4,6 +4,8 @@ Your personal AI assistant that remembers everything across **Telegram, Slack, D
|
||||
|
||||
<img width="750" alt="lettabot-preview" src="https://github.com/user-attachments/assets/9f01b845-d5b0-447b-927d-ae15f9ec7511" />
|
||||
|
||||
[](https://railway.com/deploy/lettabot)
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Channel** - Chat seamlessly across Telegram, Slack, Discord, WhatsApp, and Signal
|
||||
|
||||
111
docs/railway-deploy.md
Normal file
111
docs/railway-deploy.md
Normal file
@@ -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)
|
||||
```
|
||||
20
railway.toml
Normal file
20
railway.toml
Normal file
@@ -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
|
||||
@@ -258,13 +258,18 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
async start(): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
40
src/main.ts
40
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'}`);
|
||||
|
||||
@@ -184,3 +184,30 @@ export async function listAgents(query?: string): Promise<Array<{ id: string; na
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an agent by exact name match
|
||||
* Returns the most recently created agent if multiple match
|
||||
*/
|
||||
export async function findAgentByName(name: string): Promise<{ id: string; name: string } | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user