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 # Config with secrets
lettabot.yaml lettabot.yaml
lettabot.yml lettabot.yml
# Platform-specific deploy configs (generated by fly launch, etc.)
fly.toml
bun.lock bun.lock
.tool-versions .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`: LettaBot supports pairing-based access control. When `TELEGRAM_DM_POLICY=pairing`:
1. Unauthorized users get a pairing code 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 3. Approved users can then chat with the bot
## Development ## Development

View File

@@ -5,7 +5,8 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t
## Guides ## Guides
- [Getting Started](./getting-started.md) - Installation and basic setup - [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 - [Configuration Reference](./configuration.md) - All config options
- [Commands Reference](./commands.md) - Bot commands reference - [Commands Reference](./commands.md) - Bot commands reference
- [CLI Tools](./cli-tools.md) - Agent/operator CLI tools - [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.) - [Response Directives](./directives.md) - XML action directives (reactions, etc.)
- [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats - [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats
- [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration - [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 - [Releasing](./releasing.md) - How to create releases
### Channel Setup ### 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. 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 1. `LETTABOT_CONFIG_YAML` env var - Inline YAML or base64-encoded YAML (recommended for cloud/Docker)
2. `./lettabot.yaml` - Project-local (recommended) 2. `LETTABOT_CONFIG` env var - Explicit file path override
3. `./lettabot.yml` - Project-local alternate 3. `./lettabot.yaml` - Project-local (recommended for local dev)
4. `~/.lettabot/config.yaml` - User global 4. `./lettabot.yml` - Project-local alternate
5. `~/.lettabot/config.yml` - User global alternate 5. `~/.lettabot/config.yaml` - User global
6. `~/.lettabot/config.yml` - User global alternate
For global installs (`npm install -g`), either: ### Cloud / Docker Deployments
- Create `~/.lettabot/config.yaml`, or
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` - Set `export LETTABOT_CONFIG=/path/to/your/config.yaml`
## Example Configuration ## Example Configuration

View File

@@ -1,6 +1,6 @@
# Railway Deployment # 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 ## 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. **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 | | Variable | Description |
|----------|-------------| |----------|-------------|
| `LETTA_API_KEY` | Your Letta API key ([get one here](https://app.letta.com)) | | `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:** **Telegram:**
``` ```
@@ -39,7 +57,7 @@ SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-... SLACK_APP_TOKEN=xapp-...
``` ```
### Optional #### Optional
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
@@ -107,23 +125,39 @@ LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persi
## Remote Pairing Approval ## 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 ```bash
# List pending pairing requests for a channel # List pending pairing requests for a channel
curl -H "X-Api-Key: $LETTABOT_API_KEY" \ 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 # Approve a pairing code
curl -X POST \ curl -X POST \
-H "X-Api-Key: $LETTABOT_API_KEY" \ -H "X-Api-Key: $LETTABOT_API_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"code": "ABCD1234"}' \ -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. Alternatively, use `allowlist` DM policy and pre-configure allowed users in environment variables to skip pairing entirely.
## Channel Limitations ## Channel Limitations

View File

@@ -107,6 +107,8 @@ server:
### Docker Compose ### Docker Compose
Run both the Letta server and LettaBot together:
```yaml ```yaml
services: services:
letta: letta:
@@ -123,15 +125,17 @@ services:
depends_on: depends_on:
- letta - letta
environment: environment:
- LETTA_BASE_URL=http://letta:8283 - LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML}
volumes: # Your YAML config should include: server.baseUrl: http://letta:8283
- ./lettabot.yaml:/app/lettabot.yaml ports:
- ./lettabot-agent.json:/app/lettabot-agent.json - "8080:8080"
volumes: volumes:
letta-data: letta-data:
``` ```
For general Docker and cloud deployment (without a self-hosted Letta server), see [Cloud Deployment](./cloud-deploy.md).
## Troubleshooting ## Troubleshooting
### Connection Refused ### 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'); 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; 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 // Route: 404 Not Found
sendError(res, 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.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response)); 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 * 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'; import { loadAppConfigOrExit, applyConfigToEnv, serverModeLabel } from './config/index.js';
const config = loadAppConfigOrExit(); let cachedConfig: LettaBotConfig | null = null;
applyConfigToEnv(config);
function getConfig(): LettaBotConfig {
if (!cachedConfig) {
cachedConfig = loadAppConfigOrExit();
applyConfigToEnv(cachedConfig);
}
return cachedConfig;
}
import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js'; import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js';
@@ -44,6 +52,7 @@ import { onboard } from './onboard.js';
async function configure() { async function configure() {
const p = await import('@clack/prompts'); const p = await import('@clack/prompts');
const { resolveConfigPath } = await import('./config/index.js'); const { resolveConfigPath } = await import('./config/index.js');
const config = getConfig();
p.intro('🤖 LettaBot Configuration'); p.intro('🤖 LettaBot Configuration');
@@ -97,21 +106,49 @@ async function configure() {
} }
} }
async function server() { async function configEncode() {
const { resolveConfigPath } = await import('./config/index.js'); const { resolveConfigPath, encodeConfigForEnv } = await import('./config/index.js');
const configPath = resolveConfigPath(); const configPath = resolveConfigPath();
// Check if configured
if (!existsSync(configPath)) { 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(` console.log(`
No config file found. Searched locations: No config file found. Searched locations:
1. LETTABOT_CONFIG env var (not set) 1. LETTABOT_CONFIG_YAML env var (inline YAML or base64 - recommended for cloud)
2. ./lettabot.yaml (project-local - recommended) 2. LETTABOT_CONFIG env var (file path)
3. ./lettabot.yml 3. ./lettabot.yaml (project-local - recommended for local dev)
4. ~/.lettabot/config.yaml (user global) 4. ./lettabot.yml
5. ~/.lettabot/config.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); process.exit(1);
} }
@@ -190,6 +227,8 @@ Commands:
onboard Setup wizard (integrations, skills, configuration) onboard Setup wizard (integrations, skills, configuration)
server Start the bot server server Start the bot server
configure View and edit configuration 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 Interactive model selector
model show Show current agent model model show Show current agent model
model set <handle> Set model by handle (e.g., anthropic/claude-sonnet-4-5-20250929) 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 lettabot pairing approve telegram ABCD1234 # Approve a pairing code
Environment: Environment:
LETTABOT_CONFIG_YAML Inline YAML or base64-encoded config (for cloud deploys)
LETTA_API_KEY API key from app.letta.com LETTA_API_KEY API key from app.letta.com
TELEGRAM_BOT_TOKEN Bot token from @BotFather TELEGRAM_BOT_TOKEN Bot token from @BotFather
TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open) TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open)
@@ -239,6 +279,7 @@ Environment:
} }
function getDefaultTodoAgentKey(): string { function getDefaultTodoAgentKey(): string {
const config = getConfig();
const configuredName = const configuredName =
(config.agent?.name?.trim()) (config.agent?.name?.trim())
|| (config.agents?.length && config.agents[0].name?.trim()) || (config.agents?.length && config.agents[0].name?.trim())
@@ -255,6 +296,19 @@ function getDefaultTodoAgentKey(): string {
} }
async function main() { 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) { switch (command) {
case 'onboard': case 'onboard':
case 'setup': case 'setup':
@@ -271,7 +325,13 @@ async function main() {
case 'configure': case 'configure':
case 'config': case 'config':
await configure(); if (subCommand === 'encode') {
await configEncode();
} else if (subCommand === 'decode') {
await configDecode();
} else {
await configure();
}
break; break;
case 'skills': { case 'skills': {
@@ -421,6 +481,7 @@ async function main() {
case 'reset-conversation': { case 'reset-conversation': {
const p = await import('@clack/prompts'); const p = await import('@clack/prompts');
const config = getConfig();
p.intro('Reset Conversation'); p.intro('Reset Conversation');

View File

@@ -1,7 +1,11 @@
/** /**
* LettaBot Configuration I/O * 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'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -26,7 +30,52 @@ const CONFIG_PATHS = [
const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml'); 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: * Priority:
* 1. LETTABOT_CONFIG env var (explicit override) * 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 { export function loadConfig(): LettaBotConfig {
_lastLoadFailed = false; _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(); const configPath = resolveConfigPath();
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
@@ -129,6 +192,18 @@ export function loadConfig(): LettaBotConfig {
*/ */
export function loadConfigStrict(): LettaBotConfig { export function loadConfigStrict(): LettaBotConfig {
_lastLoadFailed = false; _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(); const configPath = resolveConfigPath();
if (!existsSync(configPath)) { if (!existsSync(configPath)) {

View File

@@ -73,4 +73,35 @@ describe('loadAppConfigOrExit', () => {
rmSync(tmpDir, { recursive: true, force: true }); 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 type { LettaBotConfig } from './types.js';
import { loadConfigStrict, resolveConfigPath } from './io.js'; import { configSourceLabel, loadConfigStrict } from './io.js';
import { createLogger } from '../logger.js'; import { createLogger } from '../logger.js';
@@ -14,9 +14,9 @@ export function loadAppConfigOrExit(exitFn: ExitFn = process.exit): LettaBotConf
try { try {
return loadConfigStrict(); return loadConfigStrict();
} catch (err) { } catch (err) {
const configPath = resolveConfigPath(); const source = configSourceLabel();
log.error(`Failed to load ${configPath}:`, err); log.error(`Failed to load ${source}:`, err);
log.error(`Fix the errors above in ${configPath} and restart.`); log.error(`Fix the errors above in ${source} and restart.`);
return exitFn(1); return exitFn(1);
} }
} }

View File

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