feat: cloud deployment support with inline config, Dockerfile, and admin portal (#434)

This commit is contained in:
Cameron
2026-02-27 15:24:17 -08:00
committed by GitHub
parent 9421027234
commit 481b458af1
16 changed files with 682 additions and 56 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.git
.env
.env.*
lettabot.yaml
lettabot.yml
lettabot-agent.json
data/
*.log
.skills/

3
.gitignore vendored
View File

@@ -51,6 +51,9 @@ data/telegram-mtproto/
# Config with secrets
lettabot.yaml
lettabot.yml
# Platform-specific deploy configs (generated by fly launch, etc.)
fly.toml
bun.lock
.tool-versions

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "dist/main.js"]

View File

@@ -376,7 +376,10 @@ By default, the agent is restricted to **read-only** operations:
LettaBot supports pairing-based access control. When `TELEGRAM_DM_POLICY=pairing`:
1. Unauthorized users get a pairing code
2. You approve codes via `lettabot pairing approve telegram <CODE>`
2. Approve codes via:
- **Web portal** at `https://your-host/portal` (recommended for cloud deploys)
- **CLI**: `lettabot pairing approve telegram <CODE>`
- **API**: `POST /api/v1/pairing/telegram/approve`
3. Approved users can then chat with the bot
## Development

View File

@@ -5,7 +5,8 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t
## Guides
- [Getting Started](./getting-started.md) - Installation and basic setup
- [Docker Server Setup](./selfhosted-setup.md) - Run with your own Letta server
- [Cloud Deployment](./cloud-deploy.md) - Deploy to Fly.io, Railway, Docker, or any cloud platform
- [Self-Hosted Letta Server](./selfhosted-setup.md) - Run with your own Letta server (instead of Letta API)
- [Configuration Reference](./configuration.md) - All config options
- [Commands Reference](./commands.md) - Bot commands reference
- [CLI Tools](./cli-tools.md) - Agent/operator CLI tools
@@ -14,7 +15,7 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t
- [Response Directives](./directives.md) - XML action directives (reactions, etc.)
- [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats
- [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration
- [Railway Deployment](./railway-deploy.md) - Deploy to Railway
- [Railway Deployment](./railway-deploy.md) - Railway-specific setup (one-click deploy, volumes)
- [Releasing](./releasing.md) - How to create releases
### Channel Setup

155
docs/cloud-deploy.md Normal file
View File

@@ -0,0 +1,155 @@
# Cloud Deployment
Deploy LettaBot to any cloud platform that supports Docker or Node.js.
## Prerequisites
- A [Letta API key](https://app.letta.com) (or a self-hosted Letta server -- see [Docker Server Setup](./selfhosted-setup.md))
- At least one channel token (Telegram, Discord, or Slack)
- A working `lettabot.yaml` config (run `lettabot onboard` to create one)
## Configuration
Cloud platforms typically don't support config files directly. LettaBot solves this with `LETTABOT_CONFIG_YAML` -- a single environment variable containing your entire config.
### Encoding Your Config
```bash
# Using the CLI helper (recommended)
lettabot config encode
# Or manually
base64 < lettabot.yaml | tr -d '\n'
```
Set the output as `LETTABOT_CONFIG_YAML` on your platform. This is the only env var you need -- everything (API key, channels, features) is in the YAML.
Both base64-encoded and raw YAML values are accepted. Base64 is recommended since some platforms don't handle multi-line env vars well.
### Verifying
To decode and inspect what a `LETTABOT_CONFIG_YAML` value contains:
```bash
LETTABOT_CONFIG_YAML=... lettabot config decode
```
## Docker
LettaBot includes a Dockerfile for containerized deployment.
### Build and Run
```bash
docker build -t lettabot .
docker run -d \
-e LETTABOT_CONFIG_YAML="$(base64 < lettabot.yaml | tr -d '\n')" \
-p 8080:8080 \
lettabot
```
### Docker Compose
```yaml
services:
lettabot:
build: .
ports:
- "8080:8080"
environment:
- LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML}
restart: unless-stopped
```
If running alongside a self-hosted Letta server, see [Docker Server Setup](./selfhosted-setup.md) for the Letta container config.
## Fly.io
```bash
# Install CLI
brew install flyctl
fly auth login
# Launch (detects Dockerfile automatically)
fly launch
# Set your config
fly secrets set LETTABOT_CONFIG_YAML="$(base64 < lettabot.yaml | tr -d '\n')"
# Set a stable API key (optional, prevents regeneration across deploys)
fly secrets set LETTABOT_API_KEY=$(openssl rand -hex 32)
# Deploy
fly deploy
```
`fly launch` generates a `fly.toml` with your app name. Edit it to keep the bot running (Fly defaults to stopping idle machines):
```toml
[http_service]
auto_stop_machines = false
min_machines_running = 1
```
Scale to 1 machine (multiple instances would conflict on channel tokens):
```bash
fly scale count 1
```
## Railway
See [Railway Deployment](./railway-deploy.md) for the full guide including one-click deploy, persistent volumes, and Railway-specific configuration.
The short version:
1. Fork the repo and connect to Railway
2. Set `LETTABOT_CONFIG_YAML` (or individual env vars for simple setups)
3. Deploy
## Other Platforms
Any platform that runs Docker images or Node.js works. Set `LETTABOT_CONFIG_YAML` as an env var and you're done.
**Render:** Deploy from GitHub, set env var in dashboard.
**DigitalOcean App Platform:** Use the Dockerfile, set env var in app settings.
**Any VPS (EC2, Linode, Hetzner):** Build the Docker image and run it, or install Node.js and run `npm start` directly.
## Web Portal
LettaBot includes an admin portal at `/portal` for managing pairing approvals from a browser. Navigate to `https://your-host/portal` and enter your API key to:
- View pending pairing requests across all channels
- Approve users with one click
- Auto-refreshes every 10 seconds
## API Key
An API key is auto-generated on first boot and printed in logs. It's required for the web portal and HTTP API endpoints.
To make it stable across deploys, set `LETTABOT_API_KEY` as an environment variable:
```bash
# Fly.io
fly secrets set LETTABOT_API_KEY=$(openssl rand -hex 32)
# Railway / Render / etc.
# Set LETTABOT_API_KEY in the platform's env var UI
```
## Health Check
LettaBot exposes `GET /health` which returns `ok`. Configure your platform's health check to use this endpoint.
## Channel Limitations
| Channel | Cloud Support | Notes |
|---------|--------------|-------|
| Telegram | Yes | Full support |
| Discord | Yes | Full support |
| Slack | Yes | Full support |
| WhatsApp | No | Requires local QR code pairing |
| Signal | No | Requires local device registration |

View File

@@ -2,18 +2,36 @@
Complete reference for LettaBot configuration options.
## Config File Locations
## Config Sources
LettaBot checks these locations in order:
LettaBot checks these sources in priority order:
1. `LETTABOT_CONFIG` env var - Explicit path override
2. `./lettabot.yaml` - Project-local (recommended)
3. `./lettabot.yml` - Project-local alternate
4. `~/.lettabot/config.yaml` - User global
5. `~/.lettabot/config.yml` - User global alternate
1. `LETTABOT_CONFIG_YAML` env var - Inline YAML or base64-encoded YAML (recommended for cloud/Docker)
2. `LETTABOT_CONFIG` env var - Explicit file path override
3. `./lettabot.yaml` - Project-local (recommended for local dev)
4. `./lettabot.yml` - Project-local alternate
5. `~/.lettabot/config.yaml` - User global
6. `~/.lettabot/config.yml` - User global alternate
For global installs (`npm install -g`), either:
- Create `~/.lettabot/config.yaml`, or
### Cloud / Docker Deployments
On platforms where you can't include a config file (Railway, Fly.io, Render, etc.), use `LETTABOT_CONFIG_YAML` to pass your entire config as a single environment variable:
```bash
# Encode your local config as base64
lettabot config encode
# Or manually
base64 < lettabot.yaml | tr -d '\n'
```
Set the output as `LETTABOT_CONFIG_YAML` on your platform. Raw YAML is also accepted (for platforms that support multi-line env vars).
### Local Development
For local installs, either:
- Create `./lettabot.yaml` in your project, or
- Create `~/.lettabot/config.yaml` for global config, or
- Set `export LETTABOT_CONFIG=/path/to/your/config.yaml`
## Example Configuration

View File

@@ -1,6 +1,6 @@
# Railway Deployment
Deploy LettaBot to [Railway](https://railway.app) for always-on hosting.
Deploy LettaBot to [Railway](https://railway.app) for always-on hosting. For other platforms (Fly.io, Docker, Render), see [Cloud Deployment](./cloud-deploy.md).
## One-Click Deploy
@@ -11,15 +11,33 @@ Deploy LettaBot to [Railway](https://railway.app) for always-on hosting.
**No local setup required.** LettaBot automatically finds or creates your agent by name.
## Environment Variables
## Configuration
### Required
### Option A: Full YAML Config (Recommended)
Use `LETTABOT_CONFIG_YAML` to pass your entire `lettabot.yaml` as a single base64-encoded environment variable. This gives you access to the full config schema (multi-agent, conversation routing, group policies, etc.) without managing dozens of individual env vars.
```bash
# Encode your local config
base64 < lettabot.yaml | tr -d '\n'
# Or use the CLI helper
lettabot config encode
```
Set the output as `LETTABOT_CONFIG_YAML` in your Railway service variables. That's it -- no other env vars needed (everything is in the YAML).
### Option B: Individual Environment Variables
For simple setups (one channel, basic config), you can use individual env vars instead.
#### Required
| Variable | Description |
|----------|-------------|
| `LETTA_API_KEY` | Your Letta API key ([get one here](https://app.letta.com)) |
### Channel Configuration (at least one required)
#### Channel Configuration (at least one required)
**Telegram:**
```
@@ -39,7 +57,7 @@ SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
```
### Optional
#### Optional
| Variable | Default | Description |
|----------|---------|-------------|
@@ -107,23 +125,39 @@ LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persi
## Remote Pairing Approval
When using `pairing` DM policy on Railway, you can approve new users via the HTTP API instead of the CLI:
When using `pairing` DM policy on a cloud deployment, you need a way to approve new users without CLI access.
### Web Portal
Navigate to `https://your-app/portal` to access the admin portal. It provides a UI for managing pairing requests across all channels (Telegram, Discord, Slack).
You'll need your `LETTABOT_API_KEY` to log in. The key is auto-generated on first boot and printed in logs. Set it as an environment variable for stable access across deploys:
```bash
# Railway
# Set LETTABOT_API_KEY in service variables
# Fly.io
fly secrets set LETTABOT_API_KEY=your-key -a your-app
```
### API
You can also approve pairings via the HTTP API:
```bash
# List pending pairing requests for a channel
curl -H "X-Api-Key: $LETTABOT_API_KEY" \
https://your-app.railway.app/api/v1/pairing/telegram
https://your-app/api/v1/pairing/telegram
# Approve a pairing code
curl -X POST \
-H "X-Api-Key: $LETTABOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"code": "ABCD1234"}' \
https://your-app.railway.app/api/v1/pairing/telegram/approve
https://your-app/api/v1/pairing/telegram/approve
```
`LETTABOT_API_KEY` is auto-generated on first boot and printed in logs. Set it as a Railway variable for stable access across deploys.
Alternatively, use `allowlist` DM policy and pre-configure allowed users in environment variables to skip pairing entirely.
## Channel Limitations

View File

@@ -107,6 +107,8 @@ server:
### Docker Compose
Run both the Letta server and LettaBot together:
```yaml
services:
letta:
@@ -123,15 +125,17 @@ services:
depends_on:
- letta
environment:
- LETTA_BASE_URL=http://letta:8283
volumes:
- ./lettabot.yaml:/app/lettabot.yaml
- ./lettabot-agent.json:/app/lettabot-agent.json
- LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML}
# Your YAML config should include: server.baseUrl: http://letta:8283
ports:
- "8080:8080"
volumes:
letta-data:
```
For general Docker and cloud deployment (without a self-hosted Letta server), see [Cloud Deployment](./cloud-deploy.md).
## Troubleshooting
### Connection Refused

View File

@@ -210,3 +210,32 @@ describe('POST /api/v1/chat', () => {
expect(events.find((e: any) => e.type === 'error').error).toBe('connection lost');
});
});
describe('GET /portal', () => {
let server: http.Server;
let port: number;
beforeAll(async () => {
server = createApiServer(createMockRouter(), {
port: TEST_PORT,
apiKey: TEST_API_KEY,
host: '127.0.0.1',
});
await new Promise<void>((resolve) => {
if (server.listening) { resolve(); return; }
server.once('listening', resolve);
});
port = getPort(server);
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('serves the pairing portal HTML without requiring an API key', async () => {
const res = await request(port, 'GET', '/portal');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.body).toContain('<title>LettaBot Portal</title>');
});
});

View File

@@ -484,6 +484,13 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions):
return;
}
// Route: GET /portal - Admin portal for pairing approvals
if ((req.url === '/portal' || req.url === '/portal/') && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(portalHtml);
return;
}
// Route: 404 Not Found
sendError(res, 404, 'Not found');
});
@@ -569,3 +576,173 @@ function sendError(res: http.ServerResponse, status: number, message: string, fi
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
}
/**
* Admin portal HTML - self-contained page for pairing approvals
*/
const portalHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LettaBot Portal</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0a0a0a; color: #e0e0e0; min-height: 100vh; }
.container { max-width: 640px; margin: 0 auto; padding: 24px 16px; }
h1 { font-size: 18px; font-weight: 600; margin-bottom: 24px; color: #fff; }
h1 span { color: #666; font-weight: 400; }
/* Auth */
.auth { background: #141414; border: 1px solid #222; border-radius: 8px; padding: 24px; margin-bottom: 24px; }
.auth label { display: block; font-size: 13px; color: #888; margin-bottom: 8px; }
.auth input { width: 100%; padding: 10px 12px; background: #0a0a0a; border: 1px solid #333; border-radius: 6px; color: #fff; font-size: 14px; font-family: monospace; }
.auth input:focus { outline: none; border-color: #555; }
.auth button { margin-top: 12px; padding: 8px 20px; background: #fff; color: #000; border: none; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; }
.auth button:hover { background: #ddd; }
/* Tabs */
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.tab { padding: 6px 14px; background: #141414; border: 1px solid #222; border-radius: 6px; font-size: 13px; cursor: pointer; color: #888; }
.tab:hover { color: #ccc; border-color: #333; }
.tab.active { background: #1a1a1a; color: #fff; border-color: #444; }
.tab .count { background: #333; color: #aaa; font-size: 11px; padding: 1px 6px; border-radius: 10px; margin-left: 6px; }
.tab.active .count { background: #fff; color: #000; }
/* Table */
.requests { background: #141414; border: 1px solid #222; border-radius: 8px; overflow: hidden; }
.request { display: flex; align-items: center; padding: 14px 16px; border-bottom: 1px solid #1a1a1a; gap: 16px; }
.request:last-child { border-bottom: none; }
.code { font-family: monospace; font-size: 15px; font-weight: 600; color: #fff; min-width: 90px; }
.meta { flex: 1; }
.meta .name { font-size: 13px; color: #ccc; }
.meta .time { font-size: 12px; color: #555; margin-top: 2px; }
.approve-btn { padding: 6px 16px; background: #1a7f37; color: #fff; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; white-space: nowrap; }
.approve-btn:hover { background: #238636; }
.approve-btn:disabled { background: #333; color: #666; cursor: default; }
/* Empty */
.empty { padding: 40px 16px; text-align: center; color: #555; font-size: 14px; }
/* Toast */
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.toast.show { opacity: 1; }
.toast.ok { background: #1a7f37; color: #fff; }
.toast.err { background: #d1242f; color: #fff; }
/* Status bar */
.status { font-size: 12px; color: #444; text-align: center; margin-top: 16px; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="container">
<h1>LettaBot <span>Portal</span></h1>
<div class="auth" id="auth">
<label for="key">API Key</label>
<input type="password" id="key" placeholder="Paste your LETTABOT_API_KEY" autocomplete="off" onkeydown="if(event.key==='Enter')login()">
<button onclick="login()">Connect</button>
</div>
<div id="app" class="hidden">
<div class="tabs" id="tabs"></div>
<div class="requests" id="list"></div>
<div class="status" id="status"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const CHANNELS = ['telegram', 'discord', 'slack'];
let apiKey = sessionStorage.getItem('lbkey') || '';
let activeChannel = 'telegram';
let data = {};
let refreshTimer;
function login() {
apiKey = document.getElementById('key').value.trim();
if (!apiKey) return;
sessionStorage.setItem('lbkey', apiKey);
init();
}
async function init() {
document.getElementById('auth').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
await refresh();
refreshTimer = setInterval(refresh, 10000);
}
async function apiFetch(path, opts = {}) {
const res = await fetch(path, { ...opts, headers: { 'X-Api-Key': apiKey, 'Content-Type': 'application/json', ...opts.headers } });
if (res.status === 401) { sessionStorage.removeItem('lbkey'); apiKey = ''; document.getElementById('auth').classList.remove('hidden'); document.getElementById('app').classList.add('hidden'); clearInterval(refreshTimer); toast('Invalid API key', true); throw new Error('Unauthorized'); }
return res;
}
async function refresh() {
for (const ch of CHANNELS) {
try {
const res = await apiFetch('/api/v1/pairing/' + ch);
const json = await res.json();
data[ch] = json.requests || [];
} catch (e) { if (e.message === 'Unauthorized') return; data[ch] = []; }
}
renderTabs();
renderList();
document.getElementById('status').textContent = 'Updated ' + new Date().toLocaleTimeString();
}
function renderTabs() {
const el = document.getElementById('tabs');
el.innerHTML = CHANNELS.map(ch => {
const n = (data[ch] || []).length;
const cls = ch === activeChannel ? 'tab active' : 'tab';
const count = n > 0 ? '<span class="count">' + n + '</span>' : '';
return '<div class="' + cls + '" onclick="switchTab(\\'' + ch + '\\')">' + ch.charAt(0).toUpperCase() + ch.slice(1) + count + '</div>';
}).join('');
}
function renderList() {
const el = document.getElementById('list');
const items = data[activeChannel] || [];
if (items.length === 0) { el.innerHTML = '<div class="empty">No pending pairing requests</div>'; return; }
el.innerHTML = items.map(r => {
const name = r.meta?.username ? '@' + r.meta.username : r.meta?.firstName || 'User ' + r.id;
const ago = timeAgo(r.createdAt);
return '<div class="request"><div class="code">' + esc(r.code) + '</div><div class="meta"><div class="name">' + esc(name) + '</div><div class="time">' + ago + '</div></div><button class="approve-btn" onclick="approve(\\'' + activeChannel + '\\',\\'' + r.code + '\\', this)">Approve</button></div>';
}).join('');
}
function switchTab(ch) { activeChannel = ch; renderTabs(); renderList(); }
async function approve(channel, code, btn) {
btn.disabled = true; btn.textContent = '...';
try {
const res = await apiFetch('/api/v1/pairing/' + channel + '/approve', { method: 'POST', body: JSON.stringify({ code }) });
const json = await res.json();
if (json.success) { toast('Approved'); await refresh(); }
else { toast(json.error || 'Failed', true); btn.disabled = false; btn.textContent = 'Approve'; }
} catch (e) { toast('Error: ' + e.message, true); btn.disabled = false; btn.textContent = 'Approve'; }
}
function toast(msg, err) {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'toast show ' + (err ? 'err' : 'ok');
setTimeout(() => el.className = 'toast', 2500);
}
function timeAgo(iso) {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s/60) + 'm ago';
return Math.floor(s/3600) + 'h ago';
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
if (apiKey) init();
</script>
</body>
</html>`;

View File

@@ -8,10 +8,18 @@
* lettabot configure - Configure settings
*/
// Config loaded from lettabot.yaml
// Config loaded from lettabot.yaml (lazily, so debug/help commands can run with broken config)
import type { LettaBotConfig } from './config/index.js';
import { loadAppConfigOrExit, applyConfigToEnv, serverModeLabel } from './config/index.js';
const config = loadAppConfigOrExit();
applyConfigToEnv(config);
let cachedConfig: LettaBotConfig | null = null;
function getConfig(): LettaBotConfig {
if (!cachedConfig) {
cachedConfig = loadAppConfigOrExit();
applyConfigToEnv(cachedConfig);
}
return cachedConfig;
}
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js';
@@ -44,6 +52,7 @@ import { onboard } from './onboard.js';
async function configure() {
const p = await import('@clack/prompts');
const { resolveConfigPath } = await import('./config/index.js');
const config = getConfig();
p.intro('🤖 LettaBot Configuration');
@@ -97,21 +106,49 @@ async function configure() {
}
}
async function server() {
const { resolveConfigPath } = await import('./config/index.js');
async function configEncode() {
const { resolveConfigPath, encodeConfigForEnv } = await import('./config/index.js');
const configPath = resolveConfigPath();
// Check if configured
if (!existsSync(configPath)) {
console.error(`No config file found at ${configPath}`);
process.exit(1);
}
const content = readFileSync(configPath, 'utf-8');
const encoded = encodeConfigForEnv(content);
console.log('Set this environment variable on your cloud platform:\n');
console.log(`LETTABOT_CONFIG_YAML=${encoded}`);
console.log(`\nSource: ${configPath} (${content.length} bytes -> ${encoded.length} chars base64)`);
}
async function configDecode() {
if (!process.env.LETTABOT_CONFIG_YAML) {
console.error('LETTABOT_CONFIG_YAML is not set');
process.exit(1);
}
const { decodeYamlOrBase64 } = await import('./config/index.js');
console.log(decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML));
}
async function server() {
const { resolveConfigPath, hasInlineConfig } = await import('./config/index.js');
const configPath = resolveConfigPath();
// Check if configured (inline config or file)
if (!existsSync(configPath) && !hasInlineConfig()) {
console.log(`
No config file found. Searched locations:
1. LETTABOT_CONFIG env var (not set)
2. ./lettabot.yaml (project-local - recommended)
3. ./lettabot.yml
4. ~/.lettabot/config.yaml (user global)
5. ~/.lettabot/config.yml
1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud)
2. LETTABOT_CONFIG env var (file path)
3. ./lettabot.yaml (project-local - recommended for local dev)
4. ./lettabot.yml
5. ~/.lettabot/config.yaml (user global)
6. ~/.lettabot/config.yml
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG=/path/to/config.yaml
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG_YAML for cloud deploys.
Encode your config: base64 < lettabot.yaml | tr -d '\\n'
`);
process.exit(1);
}
@@ -190,6 +227,8 @@ Commands:
onboard Setup wizard (integrations, skills, configuration)
server Start the bot server
configure View and edit configuration
config encode Encode config file as base64 for LETTABOT_CONFIG_YAML
config decode Decode and print LETTABOT_CONFIG_YAML env var
model Interactive model selector
model show Show current agent model
model set <handle> Set model by handle (e.g., anthropic/claude-sonnet-4-5-20250929)
@@ -225,6 +264,7 @@ Examples:
lettabot pairing approve telegram ABCD1234 # Approve a pairing code
Environment:
LETTABOT_CONFIG_YAML Inline YAML or base64-encoded config (for cloud deploys)
LETTA_API_KEY API key from app.letta.com
TELEGRAM_BOT_TOKEN Bot token from @BotFather
TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open)
@@ -239,6 +279,7 @@ Environment:
}
function getDefaultTodoAgentKey(): string {
const config = getConfig();
const configuredName =
(config.agent?.name?.trim())
|| (config.agents?.length && config.agents[0].name?.trim())
@@ -255,6 +296,19 @@ function getDefaultTodoAgentKey(): string {
}
async function main() {
// Most commands expect config-derived env vars to be applied.
// Skip bootstrap for help/no-command and config encode/decode so these still work
// when the current config is broken.
if (
command &&
command !== 'help' &&
command !== '-h' &&
command !== '--help' &&
!(command === 'config' && (subCommand === 'encode' || subCommand === 'decode'))
) {
getConfig();
}
switch (command) {
case 'onboard':
case 'setup':
@@ -271,7 +325,13 @@ async function main() {
case 'configure':
case 'config':
if (subCommand === 'encode') {
await configEncode();
} else if (subCommand === 'decode') {
await configDecode();
} else {
await configure();
}
break;
case 'skills': {
@@ -421,6 +481,7 @@ async function main() {
case 'reset-conversation': {
const p = await import('@clack/prompts');
const config = getConfig();
p.intro('Reset Conversation');

View File

@@ -1,7 +1,11 @@
/**
* LettaBot Configuration I/O
*
* Config file location: ~/.lettabot/config.yaml (or ./lettabot.yaml in project)
* Config sources (checked in priority order):
* 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64-encoded YAML)
* 2. LETTABOT_CONFIG env var (file path)
* 3. ./lettabot.yaml or ./lettabot.yml (project-local)
* 4. ~/.lettabot/config.yaml or ~/.lettabot/config.yml (user global)
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -26,7 +30,52 @@ const CONFIG_PATHS = [
const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml');
/**
* Find the config file path (first existing, or default)
* Whether inline config is available via LETTABOT_CONFIG_YAML env var.
* When set, this takes priority over all file-based config sources.
*/
export function hasInlineConfig(): boolean {
return !!process.env.LETTABOT_CONFIG_YAML;
}
/**
* Decode a value that may be raw YAML or base64-encoded YAML.
* Detection: if the value contains a colon, it's raw YAML (every valid config
* has key: value pairs). Otherwise it's base64 (which uses only [A-Za-z0-9+/=]).
*/
export function decodeYamlOrBase64(value: string): string {
if (value.includes(':')) {
return value;
}
return Buffer.from(value, 'base64').toString('utf-8');
}
/**
* Decode inline config from LETTABOT_CONFIG_YAML env var.
*/
function decodeInlineConfig(): string {
return decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML!);
}
/**
* Human-readable label for where config was loaded from.
*/
export function configSourceLabel(): string {
if (hasInlineConfig()) return 'LETTABOT_CONFIG_YAML';
const path = resolveConfigPath();
return existsSync(path) ? path : 'defaults + environment variables';
}
/**
* Encode a YAML config file as a base64 string suitable for LETTABOT_CONFIG_YAML.
*/
export function encodeConfigForEnv(yamlContent: string): string {
return Buffer.from(yamlContent, 'utf-8').toString('base64');
}
/**
* Find the config file path (first existing, or default).
* Note: when LETTABOT_CONFIG_YAML is set, file-based config is bypassed
* entirely -- use hasInlineConfig() to check.
*
* Priority:
* 1. LETTABOT_CONFIG env var (explicit override)
@@ -102,10 +151,24 @@ function parseAndNormalizeConfig(content: string): LettaBotConfig {
}
/**
* Load config from YAML file
* Load config from inline env var or YAML file
*/
export function loadConfig(): LettaBotConfig {
_lastLoadFailed = false;
// Inline config takes priority over file-based config
if (hasInlineConfig()) {
try {
const content = decodeInlineConfig();
return parseAndNormalizeConfig(content);
} catch (err) {
_lastLoadFailed = true;
log.error('Failed to parse LETTABOT_CONFIG_YAML:', err);
log.warn('Using default configuration. Check your YAML syntax.');
return { ...DEFAULT_CONFIG };
}
}
const configPath = resolveConfigPath();
if (!existsSync(configPath)) {
@@ -129,6 +192,18 @@ export function loadConfig(): LettaBotConfig {
*/
export function loadConfigStrict(): LettaBotConfig {
_lastLoadFailed = false;
// Inline config takes priority over file-based config
if (hasInlineConfig()) {
try {
const content = decodeInlineConfig();
return parseAndNormalizeConfig(content);
} catch (err) {
_lastLoadFailed = true;
throw err;
}
}
const configPath = resolveConfigPath();
if (!existsSync(configPath)) {

View File

@@ -73,4 +73,35 @@ describe('loadAppConfigOrExit', () => {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('should mention LETTABOT_CONFIG_YAML when inline config is invalid', () => {
const originalInline = process.env.LETTABOT_CONFIG_YAML;
const originalPath = process.env.LETTABOT_CONFIG;
try {
process.env.LETTABOT_CONFIG_YAML = 'server:\n api: port: 6702\n';
delete process.env.LETTABOT_CONFIG;
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const exit = (code: number): never => {
throw new Error(`exit:${code}`);
};
expect(() => loadAppConfigOrExit(exit)).toThrow('exit:1');
expect(errorSpy).toHaveBeenNthCalledWith(
1,
expect.stringContaining('Failed to load LETTABOT_CONFIG_YAML'),
expect.anything()
);
expect(errorSpy).toHaveBeenNthCalledWith(
2,
expect.stringContaining('Fix the errors above in LETTABOT_CONFIG_YAML')
);
errorSpy.mockRestore();
} finally {
process.env.LETTABOT_CONFIG_YAML = originalInline;
process.env.LETTABOT_CONFIG = originalPath;
}
});
});

View File

@@ -1,5 +1,5 @@
import type { LettaBotConfig } from './types.js';
import { loadConfigStrict, resolveConfigPath } from './io.js';
import { configSourceLabel, loadConfigStrict } from './io.js';
import { createLogger } from '../logger.js';
@@ -14,9 +14,9 @@ export function loadAppConfigOrExit(exitFn: ExitFn = process.exit): LettaBotConf
try {
return loadConfigStrict();
} catch (err) {
const configPath = resolveConfigPath();
log.error(`Failed to load ${configPath}:`, err);
log.error(`Fix the errors above in ${configPath} and restart.`);
const source = configSourceLabel();
log.error(`Failed to load ${source}:`, err);
log.error(`Fix the errors above in ${source} and restart.`);
return exitFn(1);
}
}

View File

@@ -19,6 +19,8 @@ import {
applyConfigToEnv,
syncProviders,
resolveConfigPath,
configSourceLabel,
hasInlineConfig,
isDockerServerMode,
serverModeLabel,
} from './config/index.js';
@@ -31,8 +33,7 @@ import { createLogger, setLogLevel } from './logger.js';
const log = createLogger('Config');
const yamlConfig = loadAppConfigOrExit();
const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables';
log.info(`Loaded from ${configSource}`);
log.info(`Loaded from ${configSourceLabel()}`);
if (yamlConfig.agents?.length) {
log.info(`Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`);
} else {
@@ -181,19 +182,21 @@ import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/let
import { isVoiceMemoConfigured } from './skills/loader.js';
// Skills are now installed to agent-scoped location after agent creation (see bot.ts)
// Check if config exists (skip in Railway/Docker where env vars are used directly)
// Check if config exists (skip when inline config, container deploy, or env vars are used)
const configPath = resolveConfigPath();
const isContainerDeploy = !!(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.FLY_APP_NAME || process.env.DOCKER_DEPLOY);
if (!existsSync(configPath) && !isContainerDeploy) {
if (!existsSync(configPath) && !isContainerDeploy && !hasInlineConfig()) {
log.info(`
No config file found. Searched locations:
1. LETTABOT_CONFIG env var (not set)
2. ./lettabot.yaml (project-local - recommended)
3. ./lettabot.yml
4. ~/.lettabot/config.yaml (user global)
5. ~/.lettabot/config.yml
1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud)
2. LETTABOT_CONFIG env var (file path)
3. ./lettabot.yaml (project-local - recommended for local dev)
4. ./lettabot.yml
5. ~/.lettabot/config.yaml (user global)
6. ~/.lettabot/config.yml
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG=/path/to/config.yaml
Run "lettabot onboard" to create a config, or set LETTABOT_CONFIG_YAML for cloud deploys.
Encode your config: base64 < lettabot.yaml | tr -d '\\n'
`);
process.exit(1);
}