From 4a024082cc23846d13c80672d64fb2ba5f349383 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 21:22:55 -0800 Subject: [PATCH 01/25] Add self-hosted server option to onboard wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Enter self-hosted URL" option in authentication step - Defaults to http://localhost:8283 - Sets LETTA_BASE_URL so model listing works from local server - Validates connection to self-hosted server - Shows server URL in configuration summary 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta --- src/onboard.ts | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index 6cbe420..76c6efd 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -16,8 +16,9 @@ const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example'); interface OnboardConfig { // Auth - authMethod: 'keep' | 'oauth' | 'apikey' | 'skip'; + authMethod: 'keep' | 'oauth' | 'apikey' | 'selfhosted' | 'skip'; apiKey?: string; + baseUrl?: string; // Agent agentChoice: 'new' | 'existing' | 'env' | 'skip'; @@ -123,7 +124,8 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro ...(hasExistingAuth ? [{ value: 'keep', label: getAuthLabel(), hint: displayKey?.slice(0, 20) + '...' }] : []), ...(isLettaCloud ? [{ value: 'oauth', label: 'Login to Letta Platform', hint: 'Opens browser' }] : []), { value: 'apikey', label: 'Enter API Key manually', hint: 'Paste your key' }, - { value: 'skip', label: 'Skip', hint: 'Local server without auth' }, + { value: 'selfhosted', label: 'Enter self-hosted URL', hint: 'Local Letta server' }, + { value: 'skip', label: 'Skip', hint: 'Continue without auth' }, ]; const authMethod = await p.select({ @@ -194,6 +196,22 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro config.apiKey = apiKey; env.LETTA_API_KEY = apiKey; } + } else if (authMethod === 'selfhosted') { + const serverUrl = await p.text({ + message: 'Letta server URL', + placeholder: 'http://localhost:8283', + initialValue: 'http://localhost:8283', + }); + if (p.isCancel(serverUrl)) { p.cancel('Setup cancelled'); process.exit(0); } + + const url = serverUrl || 'http://localhost:8283'; + config.baseUrl = url; + env.LETTA_BASE_URL = url; + process.env.LETTA_BASE_URL = url; // Set immediately so model listing works + + // Clear any cloud API key since we're using self-hosted + delete env.LETTA_API_KEY; + delete process.env.LETTA_API_KEY; } else if (authMethod === 'keep') { // For OAuth tokens, refresh if needed if (existingTokens?.refreshToken) { @@ -238,7 +256,7 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro } } - // Validate connection (only if not skipping auth) + // Validate connection (skip if 'skip' was chosen) if (config.authMethod !== 'skip') { const keyToValidate = config.apiKey || env.LETTA_API_KEY; if (keyToValidate) { @@ -246,11 +264,16 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro } const spinner = p.spinner(); - spinner.start('Checking connection...'); + const serverLabel = config.baseUrl || 'Letta Cloud'; + spinner.start(`Checking connection to ${serverLabel}...`); try { const { testConnection } = await import('./tools/letta-api.js'); const ok = await testConnection(); - spinner.stop(ok ? 'Connected to server' : 'Connection issue'); + spinner.stop(ok ? `Connected to ${serverLabel}` : 'Connection issue'); + + if (!ok && config.authMethod === 'selfhosted') { + p.log.warn(`Could not connect to ${config.baseUrl}. Make sure the server is running.`); + } } catch { spinner.stop('Connection check skipped'); } @@ -623,7 +646,8 @@ function showSummary(config: OnboardConfig): void { keep: 'Keep existing', oauth: 'OAuth login', apikey: config.apiKey ? `API Key (${config.apiKey.slice(0, 10)}...)` : 'API Key', - skip: 'None (local server)', + selfhosted: config.baseUrl ? `Self-hosted (${config.baseUrl})` : 'Self-hosted', + skip: 'None', }[config.authMethod]; lines.push(`Auth: ${authLabel}`); From 10aa377776fac250ba257123c09d61267db52b1b Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 21:32:47 -0800 Subject: [PATCH 02/25] Add billing tier-aware model selection for free plan users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add models.json with featured models and free models (GLM-4.7, MiniMax M2.1) - Free plan users see free models first, then BYOK options - Paid users see featured models first - Self-hosted servers fetch models from server API - Show billing tier during onboard 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta --- src/models.json | 61 ++++++++++++ src/onboard.ts | 46 ++++++--- src/utils/model-selection.ts | 185 +++++++++++++++++++++++++++-------- 3 files changed, 238 insertions(+), 54 deletions(-) create mode 100644 src/models.json diff --git a/src/models.json b/src/models.json new file mode 100644 index 0000000..984857d --- /dev/null +++ b/src/models.json @@ -0,0 +1,61 @@ +[ + { + "id": "sonnet-4.5", + "handle": "anthropic/claude-sonnet-4-5-20250929", + "label": "Sonnet 4.5", + "description": "The recommended default model", + "isDefault": true, + "isFeatured": true + }, + { + "id": "opus", + "handle": "anthropic/claude-opus-4-5-20251101", + "label": "Opus 4.5", + "description": "Anthropic's best model", + "isFeatured": true + }, + { + "id": "haiku", + "handle": "anthropic/claude-haiku-4-5-20251001", + "label": "Haiku 4.5", + "description": "Anthropic's fastest model", + "isFeatured": true + }, + { + "id": "gpt-5.2-medium", + "handle": "openai/gpt-5.2", + "label": "GPT-5.2", + "description": "Latest general-purpose GPT (med reasoning)", + "isFeatured": true + }, + { + "id": "gemini-3", + "handle": "google_ai/gemini-3-pro-preview", + "label": "Gemini 3 Pro", + "description": "Google's smartest model", + "isFeatured": true + }, + { + "id": "gemini-3-flash", + "handle": "google_ai/gemini-3-flash-preview", + "label": "Gemini 3 Flash", + "description": "Google's fastest Gemini 3 model", + "isFeatured": true + }, + { + "id": "glm-4.7", + "handle": "zai/glm-4.7", + "label": "GLM-4.7", + "description": "zAI's latest coding model", + "isFeatured": true, + "free": true + }, + { + "id": "minimax-m2.1", + "handle": "minimax/MiniMax-M2.1", + "label": "MiniMax 2.1", + "description": "MiniMax's latest coding model", + "isFeatured": true, + "free": true + } +] diff --git a/src/onboard.ts b/src/onboard.ts index 76c6efd..793d269 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -19,6 +19,7 @@ interface OnboardConfig { authMethod: 'keep' | 'oauth' | 'apikey' | 'selfhosted' | 'skip'; apiKey?: string; baseUrl?: string; + billingTier?: string; // Agent agentChoice: 'new' | 'existing' | 'env' | 'skip'; @@ -360,24 +361,45 @@ async function stepModel(config: OnboardConfig, env: Record): Pr // Only for new agents if (config.agentChoice !== 'new') return; - const { buildModelOptions, handleModelSelection } = await import('./utils/model-selection.js'); + const { buildModelOptions, handleModelSelection, getBillingTier } = await import('./utils/model-selection.js'); const spinner = p.spinner(); + + // Determine if self-hosted (not Letta Cloud) + const isSelfHosted = config.authMethod === 'selfhosted'; + + // Fetch billing tier for Letta Cloud users + let billingTier: string | null = null; + if (!isSelfHosted) { + spinner.start('Checking account...'); + billingTier = await getBillingTier(); + config.billingTier = billingTier ?? undefined; + spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'unknown'}`); + } + spinner.start('Fetching models...'); - const modelOptions = await buildModelOptions(); + const modelOptions = await buildModelOptions({ billingTier, isSelfHosted }); spinner.stop('Models loaded'); - const modelChoice = await p.select({ - message: 'Select model', - options: modelOptions, - maxItems: 10, - }); - if (p.isCancel(modelChoice)) { p.cancel('Setup cancelled'); process.exit(0); } - - const selectedModel = await handleModelSelection(modelChoice, p.text); - if (selectedModel) { - config.model = selectedModel; + // Show appropriate message for free tier + if (billingTier === 'free') { + p.log.info('Free plan: GLM and MiniMax models are free. Other models require BYOK (Bring Your Own Key).'); } + + let selectedModel: string | null = null; + while (!selectedModel) { + const modelChoice = await p.select({ + message: 'Select model', + options: modelOptions, + maxItems: 12, + }); + if (p.isCancel(modelChoice)) { p.cancel('Setup cancelled'); process.exit(0); } + + selectedModel = await handleModelSelection(modelChoice, p.text); + // If null (e.g., header selected), loop again + } + + config.model = selectedModel; } async function stepChannels(config: OnboardConfig, env: Record): Promise { diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index 50498f6..e808714 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -1,62 +1,162 @@ /** * Shared utilities for model selection UI + * + * Follows letta-code approach: + * - Free plan users see free models (GLM, MiniMax) + BYOK options + * - Paid users see all models with featured/recommended at top */ import type * as p from '@clack/prompts'; +import modelsData from '../models.json' with { type: 'json' }; -export interface ModelOption { +export const models = modelsData as ModelInfo[]; + +export interface ModelInfo { + id: string; handle: string; - name: string; - display_name?: string; - tier?: string; + label: string; + description: string; + isDefault?: boolean; + isFeatured?: boolean; + free?: boolean; } -const TIER_LABELS: Record = { - 'free': '🆓 Free', - 'premium': '⭐ Premium', - 'per-inference': '💰 Pay-per-use', -}; - -const BYOK_LABEL = '🔑 BYOK'; +/** + * Get billing tier from Letta API + */ +export async function getBillingTier(): Promise { + try { + const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; + const apiKey = process.env.LETTA_API_KEY; + + // Self-hosted servers don't have billing tiers + if (baseUrl !== 'https://api.letta.com') { + return null; + } + + if (!apiKey) return 'free'; + + const response = await fetch(`${baseUrl}/v1/users/me/balance`, { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + + if (!response.ok) return 'free'; + + const data = await response.json() as { billing_tier?: string }; + return data.billing_tier?.toLowerCase() ?? 'free'; + } catch { + return 'free'; + } +} /** - * Build model selection options - * Returns array ready for @clack/prompts select() + * Get the default model for a billing tier */ -export async function buildModelOptions(): Promise> { - const { listModels } = await import('../tools/letta-api.js'); +export function getDefaultModelForTier(billingTier?: string | null): string { + // Free tier gets glm-4.7 (a free model) + if (billingTier?.toLowerCase() === 'free') { + const freeDefault = models.find(m => m.id === 'glm-4.7'); + if (freeDefault) return freeDefault.handle; + } + // Everyone else gets the standard default + const defaultModel = models.find(m => m.isDefault); + return defaultModel?.handle ?? models[0]?.handle ?? 'anthropic/claude-sonnet-4-5-20250929'; +} + +/** + * Build model selection options based on billing tier + * Returns array ready for @clack/prompts select() + * + * For free users: Show free models first, then BYOK option + * For paid users: Show featured models first, then all models + * For self-hosted: Fetch models from server + */ +export async function buildModelOptions(options?: { + billingTier?: string | null; + isSelfHosted?: boolean; +}): Promise> { + const billingTier = options?.billingTier; + const isSelfHosted = options?.isSelfHosted; + const isFreeTier = billingTier?.toLowerCase() === 'free'; - // Fetch both base and BYOK models - const [baseModels, byokModels] = await Promise.all([ - listModels({ providerCategory: 'base' }), - listModels({ providerCategory: 'byok' }), - ]); - - // Sort base models: free first, then premium, then per-inference - const sortedBase = baseModels.sort((a, b) => { - const tierOrder = ['free', 'premium', 'per-inference']; - return tierOrder.indexOf(a.tier || 'free') - tierOrder.indexOf(b.tier || 'free'); - }); - - // Sort BYOK models alphabetically - const sortedByok = byokModels.sort((a, b) => - (a.display_name || a.name).localeCompare(b.display_name || b.name) - ); + // For self-hosted servers, fetch models from server + if (isSelfHosted) { + return buildServerModelOptions(); + } const result: Array<{ value: string; label: string; hint: string }> = []; - // Add base models - result.push(...sortedBase.map(m => ({ - value: m.handle, - label: m.display_name || m.name, - hint: TIER_LABELS[m.tier || 'free'] || '', - }))); + if (isFreeTier) { + // Free tier: Show free models first + const freeModels = models.filter(m => m.free); + result.push(...freeModels.map(m => ({ + value: m.handle, + label: m.label, + hint: `🆓 Free - ${m.description}`, + }))); + + // Add BYOK header and options + result.push({ + value: '__byok_header__', + label: '── BYOK (Bring Your Own Key) ──', + hint: 'Connect your own API keys', + }); + + // Show featured non-free models as BYOK options + const byokModels = models.filter(m => m.isFeatured && !m.free); + result.push(...byokModels.map(m => ({ + value: m.handle, + label: m.label, + hint: `🔑 BYOK - ${m.description}`, + }))); + } else { + // Paid tier: Show featured models first + const featured = models.filter(m => m.isFeatured); + const nonFeatured = models.filter(m => !m.isFeatured); + + result.push(...featured.map(m => ({ + value: m.handle, + label: m.label, + hint: m.free ? `🆓 Free - ${m.description}` : `⭐ ${m.description}`, + }))); + + result.push(...nonFeatured.map(m => ({ + value: m.handle, + label: m.label, + hint: m.description, + }))); + } - // Add top 3 BYOK models inline - result.push(...sortedByok.map(m => ({ + // Add custom option + result.push({ + value: '__custom__', + label: 'Custom model', + hint: 'Enter handle: provider/model-name' + }); + + return result; +} + +/** + * Build model options from self-hosted server + */ +async function buildServerModelOptions(): Promise> { + const { listModels } = await import('../tools/letta-api.js'); + + // Fetch all models from server + const serverModels = await listModels(); + + const result: Array<{ value: string; label: string; hint: string }> = []; + + // Sort by display name + const sorted = serverModels.sort((a, b) => + (a.display_name || a.name).localeCompare(b.display_name || b.name) + ); + + result.push(...sorted.map(m => ({ value: m.handle, label: m.display_name || m.name, - hint: BYOK_LABEL, + hint: m.handle, }))); // Add custom option @@ -69,8 +169,6 @@ export async function buildModelOptions(): Promise Date: Wed, 28 Jan 2026 21:35:38 -0800 Subject: [PATCH 03/25] Fix billing tier detection - use correct endpoint and pass API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use /v1/metadata/balance endpoint (same as letta-code) - Pass API key explicitly since it may not be in process.env yet - Add debug logging for billing tier detection 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta --- src/onboard.ts | 4 +++- src/utils/model-selection.ts | 23 +++++++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index 793d269..04c0a7e 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -372,7 +372,9 @@ async function stepModel(config: OnboardConfig, env: Record): Pr let billingTier: string | null = null; if (!isSelfHosted) { spinner.start('Checking account...'); - billingTier = await getBillingTier(); + // Pass the API key explicitly since it may not be in process.env yet + const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + billingTier = await getBillingTier(apiKey); config.billingTier = billingTier ?? undefined; spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'unknown'}`); } diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index e808714..dd6f0e5 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -23,28 +23,35 @@ export interface ModelInfo { /** * Get billing tier from Letta API + * Uses /v1/metadata/balance endpoint (same as letta-code) */ -export async function getBillingTier(): Promise { +export async function getBillingTier(apiKey?: string): Promise { try { const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; - const apiKey = process.env.LETTA_API_KEY; + const key = apiKey || process.env.LETTA_API_KEY; // Self-hosted servers don't have billing tiers if (baseUrl !== 'https://api.letta.com') { return null; } - if (!apiKey) return 'free'; + if (!key) return 'free'; - const response = await fetch(`${baseUrl}/v1/users/me/balance`, { - headers: { 'Authorization': `Bearer ${apiKey}` }, + const response = await fetch(`${baseUrl}/v1/metadata/balance`, { + headers: { 'Authorization': `Bearer ${key}` }, }); - if (!response.ok) return 'free'; + if (!response.ok) { + console.error(`[BillingTier] API returned ${response.status}`); + return 'free'; + } const data = await response.json() as { billing_tier?: string }; - return data.billing_tier?.toLowerCase() ?? 'free'; - } catch { + const tier = data.billing_tier?.toLowerCase() ?? 'free'; + console.log(`[BillingTier] Got tier: ${tier}`); + return tier; + } catch (err) { + console.error(`[BillingTier] Error:`, err); return 'free'; } } From 691df41ff70bf2301461b26e4528241827f35f97 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 21:38:23 -0800 Subject: [PATCH 04/25] Add debug logging for API key and billing tier --- src/onboard.ts | 6 ++++++ src/utils/model-selection.ts | 14 ++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index 04c0a7e..ed66b1a 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -373,8 +373,14 @@ async function stepModel(config: OnboardConfig, env: Record): Pr if (!isSelfHosted) { spinner.start('Checking account...'); // Pass the API key explicitly since it may not be in process.env yet + // Priority: manually entered key > env object > process.env const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + console.log(`[Debug] config.apiKey: ${config.apiKey?.slice(0, 20)}...`); + console.log(`[Debug] env.LETTA_API_KEY: ${env.LETTA_API_KEY?.slice(0, 20)}...`); + console.log(`[Debug] process.env.LETTA_API_KEY: ${process.env.LETTA_API_KEY?.slice(0, 20)}...`); + console.log(`[Debug] Using apiKey: ${apiKey?.slice(0, 20)}...`); billingTier = await getBillingTier(apiKey); + console.log(`[Debug] billingTier result: "${billingTier}"`); config.billingTier = billingTier ?? undefined; spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'unknown'}`); } diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index dd6f0e5..3c257eb 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -35,23 +35,25 @@ export async function getBillingTier(apiKey?: string): Promise { return null; } - if (!key) return 'free'; + if (!key) { + return 'free'; + } const response = await fetch(`${baseUrl}/v1/metadata/balance`, { - headers: { 'Authorization': `Bearer ${key}` }, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${key}`, + }, }); if (!response.ok) { - console.error(`[BillingTier] API returned ${response.status}`); return 'free'; } const data = await response.json() as { billing_tier?: string }; const tier = data.billing_tier?.toLowerCase() ?? 'free'; - console.log(`[BillingTier] Got tier: ${tier}`); return tier; - } catch (err) { - console.error(`[BillingTier] Error:`, err); + } catch { return 'free'; } } From df90877d958872c1e19c8e9ec1e859dc35dd8cfb Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 21:39:33 -0800 Subject: [PATCH 05/25] Fix: Always use Letta Cloud for billing check, not .env LETTA_BASE_URL --- src/onboard.ts | 8 +------- src/utils/model-selection.ts | 17 +++++++++-------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index ed66b1a..861a79a 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -373,14 +373,8 @@ async function stepModel(config: OnboardConfig, env: Record): Pr if (!isSelfHosted) { spinner.start('Checking account...'); // Pass the API key explicitly since it may not be in process.env yet - // Priority: manually entered key > env object > process.env const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; - console.log(`[Debug] config.apiKey: ${config.apiKey?.slice(0, 20)}...`); - console.log(`[Debug] env.LETTA_API_KEY: ${env.LETTA_API_KEY?.slice(0, 20)}...`); - console.log(`[Debug] process.env.LETTA_API_KEY: ${process.env.LETTA_API_KEY?.slice(0, 20)}...`); - console.log(`[Debug] Using apiKey: ${apiKey?.slice(0, 20)}...`); - billingTier = await getBillingTier(apiKey); - console.log(`[Debug] billingTier result: "${billingTier}"`); + billingTier = await getBillingTier(apiKey, isSelfHosted); config.billingTier = billingTier ?? undefined; spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'unknown'}`); } diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index 3c257eb..3a72330 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -24,25 +24,26 @@ export interface ModelInfo { /** * Get billing tier from Letta API * Uses /v1/metadata/balance endpoint (same as letta-code) + * + * @param apiKey - The API key to use + * @param isSelfHosted - If true, skip billing check (self-hosted has no tiers) */ -export async function getBillingTier(apiKey?: string): Promise { +export async function getBillingTier(apiKey?: string, isSelfHosted?: boolean): Promise { try { - const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; - const key = apiKey || process.env.LETTA_API_KEY; - // Self-hosted servers don't have billing tiers - if (baseUrl !== 'https://api.letta.com') { + if (isSelfHosted) { return null; } - if (!key) { + if (!apiKey) { return 'free'; } - const response = await fetch(`${baseUrl}/v1/metadata/balance`, { + // Always use Letta Cloud for billing check (not process.env.LETTA_BASE_URL) + const response = await fetch('https://api.letta.com/v1/metadata/balance', { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${key}`, + 'Authorization': `Bearer ${apiKey}`, }, }); From 549e862e9a92597127ba7526753c442a1ec0ca98 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 21:43:57 -0800 Subject: [PATCH 06/25] Add BYOK provider setup step for free tier users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stepProviders before model selection (only for free tier) - Multi-select UI for choosing providers: Anthropic, OpenAI, Gemini, zAI, MiniMax, OpenRouter - Creates providers via Letta API with user's API keys - Fetch billing tier earlier in flow to enable provider step 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta --- src/onboard.ts | 108 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index 861a79a..d3faa9d 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -29,6 +29,9 @@ interface OnboardConfig { // Model (only for new agents) model?: string; + // BYOK Providers (for free tier) + providers?: Array<{ id: string; name: string; apiKey: string }>; + // Channels (with access control) telegram: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] }; slack: { enabled: boolean; appToken?: string; botToken?: string; allowedUsers?: string[] }; @@ -357,6 +360,86 @@ async function stepAgent(config: OnboardConfig, env: Record): Pr } } +// BYOK Provider definitions (same as letta-code) +const BYOK_PROVIDERS = [ + { id: 'anthropic', name: 'lc-anthropic', displayName: 'Anthropic (Claude)', providerType: 'anthropic' }, + { id: 'openai', name: 'lc-openai', displayName: 'OpenAI', providerType: 'openai' }, + { id: 'gemini', name: 'lc-gemini', displayName: 'Google Gemini', providerType: 'google_ai' }, + { id: 'zai', name: 'lc-zai', displayName: 'zAI', providerType: 'zai' }, + { id: 'minimax', name: 'lc-minimax', displayName: 'MiniMax', providerType: 'minimax' }, + { id: 'openrouter', name: 'lc-openrouter', displayName: 'OpenRouter', providerType: 'openrouter' }, +]; + +async function stepProviders(config: OnboardConfig, env: Record): Promise { + // Only for free tier users on Letta Cloud (not self-hosted, not paid) + if (config.authMethod === 'selfhosted') return; + if (config.billingTier !== 'free') return; + + const selectedProviders = await p.multiselect({ + message: 'Add LLM provider keys (optional - for BYOK models)', + options: BYOK_PROVIDERS.map(provider => ({ + value: provider.id, + label: provider.displayName, + hint: `Connect your ${provider.displayName} API key`, + })), + required: false, + }); + + if (p.isCancel(selectedProviders)) { p.cancel('Setup cancelled'); process.exit(0); } + + // If no providers selected, skip + if (!selectedProviders || selectedProviders.length === 0) { + return; + } + + config.providers = []; + const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + + // Collect API keys for each selected provider + for (const providerId of selectedProviders as string[]) { + const provider = BYOK_PROVIDERS.find(p => p.id === providerId); + if (!provider) continue; + + const providerKey = await p.text({ + message: `${provider.displayName} API Key`, + placeholder: 'sk-...', + }); + + if (p.isCancel(providerKey)) { p.cancel('Setup cancelled'); process.exit(0); } + + if (providerKey) { + // Create provider via Letta API + const spinner = p.spinner(); + spinner.start(`Connecting ${provider.displayName}...`); + + try { + const response = await fetch('https://api.letta.com/v1/providers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + name: provider.name, + provider_type: provider.providerType, + api_key: providerKey, + }), + }); + + if (response.ok) { + spinner.stop(`Connected ${provider.displayName}`); + config.providers.push({ id: provider.id, name: provider.name, apiKey: providerKey }); + } else { + const error = await response.text(); + spinner.stop(`Failed to connect ${provider.displayName}: ${error}`); + } + } catch (err) { + spinner.stop(`Failed to connect ${provider.displayName}`); + } + } + } +} + async function stepModel(config: OnboardConfig, env: Record): Promise { // Only for new agents if (config.agentChoice !== 'new') return; @@ -368,11 +451,10 @@ async function stepModel(config: OnboardConfig, env: Record): Pr // Determine if self-hosted (not Letta Cloud) const isSelfHosted = config.authMethod === 'selfhosted'; - // Fetch billing tier for Letta Cloud users - let billingTier: string | null = null; - if (!isSelfHosted) { + // Fetch billing tier for Letta Cloud users (if not already fetched) + let billingTier: string | null = config.billingTier || null; + if (!isSelfHosted && !billingTier) { spinner.start('Checking account...'); - // Pass the API key explicitly since it may not be in process.env yet const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; billingTier = await getBillingTier(apiKey, isSelfHosted); config.billingTier = billingTier ?? undefined; @@ -729,7 +811,10 @@ async function reviewLoop(config: OnboardConfig, env: Record): P if (choice === 'auth') await stepAuth(config, env); else if (choice === 'agent') { await stepAgent(config, env); - if (config.agentChoice === 'new') await stepModel(config, env); + if (config.agentChoice === 'new') { + await stepProviders(config, env); + await stepModel(config, env); + } } else if (choice === 'channels') await stepChannels(config, env); else if (choice === 'features') await stepFeatures(config); @@ -805,6 +890,19 @@ export async function onboard(): Promise { // Run through all steps await stepAuth(config, env); await stepAgent(config, env); + + // Fetch billing tier for free plan detection (only for Letta Cloud) + if (config.authMethod !== 'selfhosted' && config.agentChoice === 'new') { + const { getBillingTier } = await import('./utils/model-selection.js'); + const spinner = p.spinner(); + spinner.start('Checking account...'); + const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + const billingTier = await getBillingTier(apiKey, false); + config.billingTier = billingTier ?? undefined; + spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'Pro'}`); + } + + await stepProviders(config, env); await stepModel(config, env); await stepChannels(config, env); await stepFeatures(config); From 89d4b5d08201acfc526eca01d1e7d0d63c1b3e7a Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 21:46:46 -0800 Subject: [PATCH 07/25] Fix: Show models from connected BYOK providers, not all cloud models --- src/onboard.ts | 4 ++- src/utils/model-selection.ts | 54 +++++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index d3faa9d..08bd825 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -462,7 +462,9 @@ async function stepModel(config: OnboardConfig, env: Record): Pr } spinner.start('Fetching models...'); - const modelOptions = await buildModelOptions({ billingTier, isSelfHosted }); + // Pass connected provider IDs so we show models from those providers + const connectedProviders = config.providers?.map(p => p.id) || []; + const modelOptions = await buildModelOptions({ billingTier, isSelfHosted, connectedProviders }); spinner.stop('Models loaded'); // Show appropriate message for free tier diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index 3a72330..cb6ea86 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -73,20 +73,32 @@ export function getDefaultModelForTier(billingTier?: string | null): string { return defaultModel?.handle ?? models[0]?.handle ?? 'anthropic/claude-sonnet-4-5-20250929'; } +// Map provider IDs to model handle prefixes +const PROVIDER_TO_MODEL_PREFIX: Record = { + 'anthropic': ['anthropic/'], + 'openai': ['openai/'], + 'gemini': ['google_ai/'], + 'zai': ['zai/'], + 'minimax': ['minimax/'], + 'openrouter': ['openrouter/'], +}; + /** - * Build model selection options based on billing tier + * Build model selection options based on billing tier and connected providers * Returns array ready for @clack/prompts select() * - * For free users: Show free models first, then BYOK option + * For free users: Show free models first, then connected BYOK models * For paid users: Show featured models first, then all models * For self-hosted: Fetch models from server */ export async function buildModelOptions(options?: { billingTier?: string | null; isSelfHosted?: boolean; + connectedProviders?: string[]; // Provider IDs like 'anthropic', 'minimax' }): Promise> { const billingTier = options?.billingTier; const isSelfHosted = options?.isSelfHosted; + const connectedProviders = options?.connectedProviders || []; const isFreeTier = billingTier?.toLowerCase() === 'free'; // For self-hosted servers, fetch models from server @@ -96,6 +108,14 @@ export async function buildModelOptions(options?: { const result: Array<{ value: string; label: string; hint: string }> = []; + // Helper to check if a model is from a connected provider + const isFromConnectedProvider = (handle: string) => { + return connectedProviders.some(providerId => { + const prefixes = PROVIDER_TO_MODEL_PREFIX[providerId] || []; + return prefixes.some(prefix => handle.startsWith(prefix)); + }); + }; + if (isFreeTier) { // Free tier: Show free models first const freeModels = models.filter(m => m.free); @@ -105,20 +125,22 @@ export async function buildModelOptions(options?: { hint: `🆓 Free - ${m.description}`, }))); - // Add BYOK header and options - result.push({ - value: '__byok_header__', - label: '── BYOK (Bring Your Own Key) ──', - hint: 'Connect your own API keys', - }); - - // Show featured non-free models as BYOK options - const byokModels = models.filter(m => m.isFeatured && !m.free); - result.push(...byokModels.map(m => ({ - value: m.handle, - label: m.label, - hint: `🔑 BYOK - ${m.description}`, - }))); + // If user has connected providers, show their models + if (connectedProviders.length > 0) { + result.push({ + value: '__byok_header__', + label: '── Your Connected Providers ──', + hint: 'Models from your API keys', + }); + + // Show models from connected providers only + const connectedModels = models.filter(m => !m.free && isFromConnectedProvider(m.handle)); + result.push(...connectedModels.map(m => ({ + value: m.handle, + label: m.label, + hint: `🔑 ${m.description}`, + }))); + } } else { // Paid tier: Show featured models first const featured = models.filter(m => m.isFeatured); From e6038faf96dfc0cdde28b2d30ec086879977d60b Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:15:35 -0800 Subject: [PATCH 08/25] Show all BYOK models for free tier users --- src/onboard.ts | 4 +-- src/utils/model-selection.ts | 54 +++++++++++------------------------- 2 files changed, 17 insertions(+), 41 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index 08bd825..d3faa9d 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -462,9 +462,7 @@ async function stepModel(config: OnboardConfig, env: Record): Pr } spinner.start('Fetching models...'); - // Pass connected provider IDs so we show models from those providers - const connectedProviders = config.providers?.map(p => p.id) || []; - const modelOptions = await buildModelOptions({ billingTier, isSelfHosted, connectedProviders }); + const modelOptions = await buildModelOptions({ billingTier, isSelfHosted }); spinner.stop('Models loaded'); // Show appropriate message for free tier diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index cb6ea86..f427fd7 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -73,32 +73,20 @@ export function getDefaultModelForTier(billingTier?: string | null): string { return defaultModel?.handle ?? models[0]?.handle ?? 'anthropic/claude-sonnet-4-5-20250929'; } -// Map provider IDs to model handle prefixes -const PROVIDER_TO_MODEL_PREFIX: Record = { - 'anthropic': ['anthropic/'], - 'openai': ['openai/'], - 'gemini': ['google_ai/'], - 'zai': ['zai/'], - 'minimax': ['minimax/'], - 'openrouter': ['openrouter/'], -}; - /** - * Build model selection options based on billing tier and connected providers + * Build model selection options based on billing tier * Returns array ready for @clack/prompts select() * - * For free users: Show free models first, then connected BYOK models + * For free users: Show free models first, then all BYOK models * For paid users: Show featured models first, then all models * For self-hosted: Fetch models from server */ export async function buildModelOptions(options?: { billingTier?: string | null; isSelfHosted?: boolean; - connectedProviders?: string[]; // Provider IDs like 'anthropic', 'minimax' }): Promise> { const billingTier = options?.billingTier; const isSelfHosted = options?.isSelfHosted; - const connectedProviders = options?.connectedProviders || []; const isFreeTier = billingTier?.toLowerCase() === 'free'; // For self-hosted servers, fetch models from server @@ -108,14 +96,6 @@ export async function buildModelOptions(options?: { const result: Array<{ value: string; label: string; hint: string }> = []; - // Helper to check if a model is from a connected provider - const isFromConnectedProvider = (handle: string) => { - return connectedProviders.some(providerId => { - const prefixes = PROVIDER_TO_MODEL_PREFIX[providerId] || []; - return prefixes.some(prefix => handle.startsWith(prefix)); - }); - }; - if (isFreeTier) { // Free tier: Show free models first const freeModels = models.filter(m => m.free); @@ -125,22 +105,20 @@ export async function buildModelOptions(options?: { hint: `🆓 Free - ${m.description}`, }))); - // If user has connected providers, show their models - if (connectedProviders.length > 0) { - result.push({ - value: '__byok_header__', - label: '── Your Connected Providers ──', - hint: 'Models from your API keys', - }); - - // Show models from connected providers only - const connectedModels = models.filter(m => !m.free && isFromConnectedProvider(m.handle)); - result.push(...connectedModels.map(m => ({ - value: m.handle, - label: m.label, - hint: `🔑 ${m.description}`, - }))); - } + // Show all BYOK models + result.push({ + value: '__byok_header__', + label: '── BYOK Models ──', + hint: 'Requires provider API key', + }); + + // Show all non-free models as BYOK options + const byokModels = models.filter(m => !m.free); + result.push(...byokModels.map(m => ({ + value: m.handle, + label: m.label, + hint: `🔑 ${m.description}`, + }))); } else { // Paid tier: Show featured models first const featured = models.filter(m => m.isFeatured); From 77c8eb5b1e81160d64e43cdbf82aa5995c4835e5 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:20:35 -0800 Subject: [PATCH 09/25] Fetch BYOK models from API instead of static list --- src/onboard.ts | 3 +- src/utils/model-selection.ts | 64 +++++++++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index d3faa9d..625d091 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -462,7 +462,8 @@ async function stepModel(config: OnboardConfig, env: Record): Pr } spinner.start('Fetching models...'); - const modelOptions = await buildModelOptions({ billingTier, isSelfHosted }); + const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + const modelOptions = await buildModelOptions({ billingTier, isSelfHosted, apiKey }); spinner.stop('Models loaded'); // Show appropriate message for free tier diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index f427fd7..d39a6e8 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -73,17 +73,50 @@ export function getDefaultModelForTier(billingTier?: string | null): string { return defaultModel?.handle ?? models[0]?.handle ?? 'anthropic/claude-sonnet-4-5-20250929'; } +interface ByokModel { + handle: string; + name: string; + display_name?: string; + provider_name: string; + provider_type: string; +} + +/** + * Fetch BYOK models from Letta API + */ +async function fetchByokModels(apiKey?: string): Promise { + try { + const key = apiKey || process.env.LETTA_API_KEY; + if (!key) return []; + + const response = await fetch('https://api.letta.com/v1/models?provider_category=byok', { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${key}`, + }, + }); + + if (!response.ok) return []; + + const models = await response.json() as ByokModel[]; + return models; + } catch { + return []; + } +} + /** * Build model selection options based on billing tier * Returns array ready for @clack/prompts select() * - * For free users: Show free models first, then all BYOK models + * For free users: Show free models first, then BYOK models from API * For paid users: Show featured models first, then all models * For self-hosted: Fetch models from server */ export async function buildModelOptions(options?: { billingTier?: string | null; isSelfHosted?: boolean; + apiKey?: string; }): Promise> { const billingTier = options?.billingTier; const isSelfHosted = options?.isSelfHosted; @@ -105,20 +138,21 @@ export async function buildModelOptions(options?: { hint: `🆓 Free - ${m.description}`, }))); - // Show all BYOK models - result.push({ - value: '__byok_header__', - label: '── BYOK Models ──', - hint: 'Requires provider API key', - }); - - // Show all non-free models as BYOK options - const byokModels = models.filter(m => !m.free); - result.push(...byokModels.map(m => ({ - value: m.handle, - label: m.label, - hint: `🔑 ${m.description}`, - }))); + // Fetch BYOK models from API + const byokModels = await fetchByokModels(options?.apiKey); + if (byokModels.length > 0) { + result.push({ + value: '__byok_header__', + label: '── Your Connected Providers ──', + hint: 'Models from your API keys', + }); + + result.push(...byokModels.map(m => ({ + value: m.handle, + label: m.display_name || m.name, + hint: `🔑 ${m.provider_name}`, + }))); + } } else { // Paid tier: Show featured models first const featured = models.filter(m => m.isFeatured); From fb28ea362de7acc5f3bcdfd4c13fffc45c3e61f2 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:23:46 -0800 Subject: [PATCH 10/25] Fix: Update existing provider instead of creating duplicate --- src/onboard.ts | 46 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index 625d091..c40bbca 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -408,24 +408,54 @@ async function stepProviders(config: OnboardConfig, env: Record) if (p.isCancel(providerKey)) { p.cancel('Setup cancelled'); process.exit(0); } if (providerKey) { - // Create provider via Letta API + // Create or update provider via Letta API const spinner = p.spinner(); spinner.start(`Connecting ${provider.displayName}...`); try { - const response = await fetch('https://api.letta.com/v1/providers', { - method: 'POST', + // First check if provider already exists + const listResponse = await fetch('https://api.letta.com/v1/providers', { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, - body: JSON.stringify({ - name: provider.name, - provider_type: provider.providerType, - api_key: providerKey, - }), }); + let existingProvider: { id: string; name: string } | undefined; + if (listResponse.ok) { + const providers = await listResponse.json() as Array<{ id: string; name: string }>; + existingProvider = providers.find(p => p.name === provider.name); + } + + let response: Response; + if (existingProvider) { + // Update existing provider + response = await fetch(`https://api.letta.com/v1/providers/${existingProvider.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + api_key: providerKey, + }), + }); + } else { + // Create new provider + response = await fetch('https://api.letta.com/v1/providers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + name: provider.name, + provider_type: provider.providerType, + api_key: providerKey, + }), + }); + } + if (response.ok) { spinner.stop(`Connected ${provider.displayName}`); config.providers.push({ id: provider.id, name: provider.name, apiKey: providerKey }); From 75037239ace7bafd5121671566e12d048b1c7c08 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:29:20 -0800 Subject: [PATCH 11/25] Add detailed logging for agent message handling and response --- src/core/bot.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index 5098dc0..2671fcc 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -214,8 +214,12 @@ export class LettaBot { adapter.sendTypingIndicator(msg.chatId).catch(() => {}); }, 4000); + let streamCount = 0; try { + console.log('[Bot] Entering stream loop...'); for await (const streamMsg of session.stream()) { + streamCount++; + console.log(`[Bot] Stream msg #${streamCount}: type=${streamMsg.type}, content=${streamMsg.type === 'assistant' ? streamMsg.content?.slice(0, 50) + '...' : '(n/a)'}`); if (streamMsg.type === 'assistant') { response += streamMsg.content; @@ -260,32 +264,44 @@ export class LettaBot { clearInterval(typingInterval); } + console.log(`[Bot] Stream complete. Total messages: ${streamCount}, Response length: ${response.length}`); + console.log(`[Bot] Response preview: ${response.slice(0, 100)}...`); + // Send final response if (response) { + console.log(`[Bot] Sending final response (messageId=${messageId})`); try { if (messageId) { await adapter.editMessage(msg.chatId, messageId, response); + console.log('[Bot] Edited existing message'); } else { await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); + console.log('[Bot] Sent new message'); } - } catch { + } catch (sendError) { + console.error('[Bot] Error sending final message:', sendError); // If we already sent a streamed message, don't duplicate — the user already saw it. if (!messageId) { await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); } } } else { + console.log('[Bot] No response from agent, sending placeholder'); await adapter.sendMessage({ chatId: msg.chatId, text: '(No response from agent)', threadId: msg.threadId }); } } catch (error) { - console.error('Error processing message:', error); + console.error('[Bot] Error processing message:', error); + if (error instanceof Error) { + console.error('[Bot] Error stack:', error.stack); + } await adapter.sendMessage({ chatId: msg.chatId, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, threadId: msg.threadId, }); } finally { + console.log('[Bot] Closing session'); session!?.close(); } } From bf7bf89b80772b68bb941f59eab53efc460541e5 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:31:53 -0800 Subject: [PATCH 12/25] Add more logging for session.send() and CLI process --- src/core/bot.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index 2671fcc..ede1731 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -190,19 +190,37 @@ export class LettaBot { console.log(`[Bot] Session _agentId:`, (session as any)._agentId); console.log(`[Bot] Session options.permissionMode:`, (session as any).options?.permissionMode); - // Hook into transport errors + // Hook into transport errors and stdout const transport = (session as any).transport; if (transport?.process) { + console.log('[Bot] Transport process PID:', transport.process.pid); + transport.process.stdout?.on('data', (data: Buffer) => { + console.log('[Bot] CLI stdout:', data.toString().slice(0, 500)); + }); transport.process.stderr?.on('data', (data: Buffer) => { console.error('[Bot] CLI stderr:', data.toString()); }); + transport.process.on('exit', (code: number) => { + console.log('[Bot] CLI process exited with code:', code); + }); + transport.process.on('error', (err: Error) => { + console.error('[Bot] CLI process error:', err); + }); + } else { + console.log('[Bot] No transport process found'); } // Send message to agent with metadata envelope const formattedMessage = formatMessageEnvelope(msg); - console.log('[Bot] Sending message...'); - await session.send(formattedMessage); - console.log('[Bot] Message sent, starting stream...'); + console.log('[Bot] Formatted message:', formattedMessage.slice(0, 200)); + console.log('[Bot] Sending message to session...'); + try { + await session.send(formattedMessage); + console.log('[Bot] Message sent successfully, starting stream...'); + } catch (sendError) { + console.error('[Bot] Error in session.send():', sendError); + throw sendError; + } // Stream response let response = ''; From 59957815fb997453ba26cdc7df0ab69c2bd97c43 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:32:19 -0800 Subject: [PATCH 13/25] Log target server endpoint before sending message --- src/core/bot.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/bot.ts b/src/core/bot.ts index ede1731..5680ece 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -213,6 +213,9 @@ export class LettaBot { // Send message to agent with metadata envelope const formattedMessage = formatMessageEnvelope(msg); console.log('[Bot] Formatted message:', formattedMessage.slice(0, 200)); + console.log('[Bot] Target server:', process.env.LETTA_BASE_URL || 'https://api.letta.com (default)'); + console.log('[Bot] API key:', process.env.LETTA_API_KEY ? `${process.env.LETTA_API_KEY.slice(0, 20)}...` : '(not set)'); + console.log('[Bot] Agent ID:', this.store.agentId || '(new agent)'); console.log('[Bot] Sending message to session...'); try { await session.send(formattedMessage); From 2b5b1eda57755e662f33699809a2f28848d5ed5c Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:36:29 -0800 Subject: [PATCH 14/25] Log and timeout session initialization/send --- src/core/bot.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index 5680ece..02fb6d6 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -210,6 +210,26 @@ export class LettaBot { console.log('[Bot] No transport process found'); } + // Initialize session explicitly (so we can log timing/failures) + const initTimeoutMs = 15000; + const withTimeout = async (promise: Promise, label: string): Promise => { + let timeoutId: NodeJS.Timeout; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`${label} timed out after ${initTimeoutMs}ms`)); + }, initTimeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId!); + } + }; + + console.log('[Bot] Initializing session...'); + const initInfo = await withTimeout(session.initialize(), 'Session initialize'); + console.log('[Bot] Session initialized:', initInfo); + // Send message to agent with metadata envelope const formattedMessage = formatMessageEnvelope(msg); console.log('[Bot] Formatted message:', formattedMessage.slice(0, 200)); @@ -218,7 +238,7 @@ export class LettaBot { console.log('[Bot] Agent ID:', this.store.agentId || '(new agent)'); console.log('[Bot] Sending message to session...'); try { - await session.send(formattedMessage); + await withTimeout(session.send(formattedMessage), 'Session send'); console.log('[Bot] Message sent successfully, starting stream...'); } catch (sendError) { console.error('[Bot] Error in session.send():', sendError); From b72150c1932c1a3345b0eda63aced5ed58beb706 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:50:49 -0800 Subject: [PATCH 15/25] Add YAML config system (types and io) --- package-lock.json | 18 +++- package.json | 3 +- src/config/index.ts | 2 + src/config/io.ts | 220 ++++++++++++++++++++++++++++++++++++++++++++ src/config/types.ts | 93 +++++++++++++++++++ 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 src/config/index.ts create mode 100644 src/config/io.ts create mode 100644 src/config/types.ts diff --git a/package-lock.json b/package-lock.json index 65a9f5c..ca73b42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.8.2" }, "bin": { "lettabot": "dist/cli.js", @@ -6384,6 +6385,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index 7fd4665..d92d556 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.8.2" }, "optionalDependencies": { "@slack/bolt": "^4.6.0", diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..47bd215 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './io.js'; diff --git a/src/config/io.ts b/src/config/io.ts new file mode 100644 index 0000000..1b6fe79 --- /dev/null +++ b/src/config/io.ts @@ -0,0 +1,220 @@ +/** + * LettaBot Configuration I/O + * + * Config file location: ~/.lettabot/config.yaml (or ./lettabot.yaml in project) + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import YAML from 'yaml'; +import type { LettaBotConfig, ProviderConfig } from './types.js'; +import { DEFAULT_CONFIG } from './types.js'; + +// Config file locations (checked in order) +const CONFIG_PATHS = [ + resolve(process.cwd(), 'lettabot.yaml'), // Project-local + resolve(process.cwd(), 'lettabot.yml'), // Project-local alt + join(homedir(), '.lettabot', 'config.yaml'), // User global + join(homedir(), '.lettabot', 'config.yml'), // User global alt +]; + +const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml'); + +/** + * Find the config file path (first existing, or default) + */ +export function resolveConfigPath(): string { + for (const p of CONFIG_PATHS) { + if (existsSync(p)) { + return p; + } + } + return DEFAULT_CONFIG_PATH; +} + +/** + * Load config from YAML file + */ +export function loadConfig(): LettaBotConfig { + const configPath = resolveConfigPath(); + + if (!existsSync(configPath)) { + return { ...DEFAULT_CONFIG }; + } + + try { + const content = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(content) as Partial; + + // Merge with defaults + return { + ...DEFAULT_CONFIG, + ...parsed, + server: { ...DEFAULT_CONFIG.server, ...parsed.server }, + agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent }, + channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels }, + }; + } catch (err) { + console.error(`[Config] Failed to load ${configPath}:`, err); + return { ...DEFAULT_CONFIG }; + } +} + +/** + * Save config to YAML file + */ +export function saveConfig(config: LettaBotConfig, path?: string): void { + const configPath = path || resolveConfigPath(); + + // Ensure directory exists + const dir = dirname(configPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Convert to YAML with comments + const content = YAML.stringify(config, { + indent: 2, + lineWidth: 0, // Don't wrap lines + }); + + writeFileSync(configPath, content, 'utf-8'); + console.log(`[Config] Saved to ${configPath}`); +} + +/** + * Get environment variables from config (for backwards compatibility) + */ +export function configToEnv(config: LettaBotConfig): Record { + const env: Record = {}; + + // Server + if (config.server.mode === 'selfhosted' && config.server.baseUrl) { + env.LETTA_BASE_URL = config.server.baseUrl; + } + if (config.server.apiKey) { + env.LETTA_API_KEY = config.server.apiKey; + } + + // Agent + if (config.agent.id) { + env.LETTA_AGENT_ID = config.agent.id; + } + if (config.agent.name) { + env.AGENT_NAME = config.agent.name; + } + if (config.agent.model) { + env.MODEL = config.agent.model; + } + + // Channels + if (config.channels.telegram?.token) { + env.TELEGRAM_BOT_TOKEN = config.channels.telegram.token; + if (config.channels.telegram.dmPolicy) { + env.TELEGRAM_DM_POLICY = config.channels.telegram.dmPolicy; + } + } + if (config.channels.slack?.appToken) { + env.SLACK_APP_TOKEN = config.channels.slack.appToken; + } + if (config.channels.slack?.botToken) { + env.SLACK_BOT_TOKEN = config.channels.slack.botToken; + } + if (config.channels.whatsapp?.enabled) { + env.WHATSAPP_ENABLED = 'true'; + if (config.channels.whatsapp.selfChat) { + env.WHATSAPP_SELF_CHAT_MODE = 'true'; + } + } + if (config.channels.signal?.phone) { + env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone; + } + + // Features + if (config.features?.cron) { + env.CRON_ENABLED = 'true'; + } + if (config.features?.heartbeat?.enabled) { + env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30); + } + + return env; +} + +/** + * Apply config to process.env + */ +export function applyConfigToEnv(config: LettaBotConfig): void { + const env = configToEnv(config); + for (const [key, value] of Object.entries(env)) { + if (!process.env[key]) { + process.env[key] = value; + } + } +} + +/** + * Create BYOK providers on Letta Cloud + */ +export async function syncProviders(config: LettaBotConfig): Promise { + if (config.server.mode !== 'cloud' || !config.server.apiKey) { + return; + } + + if (!config.providers || config.providers.length === 0) { + return; + } + + const apiKey = config.server.apiKey; + const baseUrl = 'https://api.letta.com'; + + // List existing providers + const listResponse = await fetch(`${baseUrl}/v1/providers`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + const existingProviders = listResponse.ok + ? await listResponse.json() as Array<{ id: string; name: string }> + : []; + + // Create or update each provider + for (const provider of config.providers) { + const existing = existingProviders.find(p => p.name === provider.name); + + try { + if (existing) { + // Update existing + await fetch(`${baseUrl}/v1/providers/${existing.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ api_key: provider.apiKey }), + }); + console.log(`[Config] Updated provider: ${provider.name}`); + } else { + // Create new + await fetch(`${baseUrl}/v1/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + name: provider.name, + provider_type: provider.type, + api_key: provider.apiKey, + }), + }); + console.log(`[Config] Created provider: ${provider.name}`); + } + } catch (err) { + console.error(`[Config] Failed to sync provider ${provider.name}:`, err); + } + } +} diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..bf8c786 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,93 @@ +/** + * LettaBot Configuration Types + * + * Two modes: + * 1. Self-hosted: Uses baseUrl (e.g., http://localhost:8283), no API key + * 2. Letta Cloud: Uses apiKey, optional BYOK providers + */ + +export interface LettaBotConfig { + // Server connection + server: { + // 'cloud' (api.letta.com) or 'selfhosted' + mode: 'cloud' | 'selfhosted'; + // Only for selfhosted mode + baseUrl?: string; + // Only for cloud mode + apiKey?: string; + }; + + // Agent configuration + agent: { + id?: string; + name: string; + model: string; + }; + + // BYOK providers (cloud mode only) + providers?: ProviderConfig[]; + + // Channel configurations + channels: { + telegram?: TelegramConfig; + slack?: SlackConfig; + whatsapp?: WhatsAppConfig; + signal?: SignalConfig; + }; + + // Features + features?: { + cron?: boolean; + heartbeat?: { + enabled: boolean; + intervalMin?: number; + }; + }; +} + +export interface ProviderConfig { + id: string; // e.g., 'anthropic', 'openai' + name: string; // e.g., 'lc-anthropic' + type: string; // e.g., 'anthropic', 'openai' + apiKey: string; +} + +export interface TelegramConfig { + enabled: boolean; + token?: string; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +export interface SlackConfig { + enabled: boolean; + appToken?: string; + botToken?: string; + allowedUsers?: string[]; +} + +export interface WhatsAppConfig { + enabled: boolean; + selfChat?: boolean; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +export interface SignalConfig { + enabled: boolean; + phone?: string; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +// Default config +export const DEFAULT_CONFIG: LettaBotConfig = { + server: { + mode: 'cloud', + }, + agent: { + name: 'LettaBot', + model: 'zai/glm-4.7', // Free model default + }, + channels: {}, +}; From 612c7f70fe8155e68c5f1863266b5b01553a189c Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:52:14 -0800 Subject: [PATCH 16/25] Use YAML config on startup, sync BYOK providers --- src/main.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main.ts b/src/main.ts index 95c8e0c..9c92d55 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,16 @@ import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawn } from 'node:child_process'; +// Load YAML config and apply to process.env (before other imports) +import { loadConfig, applyConfigToEnv, syncProviders, resolveConfigPath } from './config/index.js'; +const yamlConfig = loadConfig(); +console.log(`[Config] Loaded from ${resolveConfigPath()}`); +console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}, Model: ${yamlConfig.agent.model}`); +applyConfigToEnv(yamlConfig); + +// Sync BYOK providers on startup (async, don't block) +syncProviders(yamlConfig).catch(err => console.error('[Config] Failed to sync providers:', err)); + // Load agent ID from store and set as env var (SDK needs this) // Load agent ID from store file, or use LETTA_AGENT_ID env var as fallback const STORE_PATH = resolve(process.cwd(), 'lettabot-agent.json'); From 75f53b24b6f8b6116195058cd9a2f34fbec65a88 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:53:11 -0800 Subject: [PATCH 17/25] Add example config file, gitignore secrets --- .gitignore | 4 ++++ lettabot.example.yaml | 52 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 lettabot.example.yaml diff --git a/.gitignore b/.gitignore index fdb4a16..c43f483 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ letta-code-sdk/ # WhatsApp session (contains credentials) data/whatsapp-session/ + +# Config with secrets +lettabot.yaml +lettabot.yml diff --git a/lettabot.example.yaml b/lettabot.example.yaml new file mode 100644 index 0000000..fa15fa7 --- /dev/null +++ b/lettabot.example.yaml @@ -0,0 +1,52 @@ +# LettaBot Configuration +# Copy this to lettabot.yaml and fill in your values. +# +# Server modes: +# - 'cloud': Use Letta Cloud (api.letta.com) with API key +# - 'selfhosted': Use self-hosted Letta server + +server: + mode: cloud + # For cloud mode, set your API key (get one at https://app.letta.com): + apiKey: sk-let-YOUR-API-KEY + # For selfhosted mode, uncomment and set the base URL: + # mode: selfhosted + # baseUrl: http://localhost:8283 + +agent: + name: LettaBot + # Model to use: + # - Free plan: zai/glm-4.7, minimax/MiniMax-M1-80k + # - BYOK: lc-anthropic/claude-sonnet-4-5-20250929, lc-openai/gpt-5.2 + model: zai/glm-4.7 + +# BYOK Providers (optional, cloud mode only) +# These will be synced to Letta Cloud on startup +# providers: +# - id: anthropic +# name: lc-anthropic +# type: anthropic +# apiKey: sk-ant-YOUR-ANTHROPIC-KEY +# - id: openai +# name: lc-openai +# type: openai +# apiKey: sk-YOUR-OPENAI-KEY + +channels: + telegram: + enabled: true + token: YOUR-TELEGRAM-BOT-TOKEN + dmPolicy: pairing # 'pairing', 'allowlist', or 'open' + # slack: + # enabled: true + # appToken: xapp-... + # botToken: xoxb-... + # whatsapp: + # enabled: true + # selfChat: false + +features: + cron: false + heartbeat: + enabled: false + intervalMin: 30 From 31feea3c80fd95bbb9d3a75e671f7ecf4731f43e Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 23:03:52 -0800 Subject: [PATCH 18/25] Update onboard to save lettabot.yaml, change Custom model to Other --- src/onboard.ts | 87 +++++++++++++++++++++++++++++++++++- src/utils/model-selection.ts | 8 ++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index c40bbca..85f0367 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -6,6 +6,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import * as p from '@clack/prompts'; +import { saveConfig, syncProviders } from './config/index.js'; +import type { LettaBotConfig, ProviderConfig } from './config/types.js'; const ENV_PATH = resolve(process.cwd(), '.env'); const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example'); @@ -1042,9 +1044,90 @@ export async function onboard(): Promise { p.note(summary, 'Configuration Summary'); - // Save + // Convert to YAML config + const yamlConfig: LettaBotConfig = { + server: { + mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud', + ...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}), + ...(config.apiKey ? { apiKey: config.apiKey } : {}), + }, + agent: { + name: config.agentName || 'LettaBot', + model: config.model || 'zai/glm-4.7', + ...(config.agentId ? { id: config.agentId } : {}), + }, + channels: { + ...(config.telegram.enabled ? { + telegram: { + enabled: true, + token: config.telegram.token, + dmPolicy: config.telegram.dmPolicy, + allowedUsers: config.telegram.allowedUsers, + } + } : {}), + ...(config.slack.enabled ? { + slack: { + enabled: true, + appToken: config.slack.appToken, + botToken: config.slack.botToken, + allowedUsers: config.slack.allowedUsers, + } + } : {}), + ...(config.whatsapp.enabled ? { + whatsapp: { + enabled: true, + selfChat: config.whatsapp.selfChat, + dmPolicy: config.whatsapp.dmPolicy, + allowedUsers: config.whatsapp.allowedUsers, + } + } : {}), + ...(config.signal.enabled ? { + signal: { + enabled: true, + phone: config.signal.phone, + dmPolicy: config.signal.dmPolicy, + allowedUsers: config.signal.allowedUsers, + } + } : {}), + }, + features: { + cron: config.cron, + heartbeat: { + enabled: config.heartbeat.enabled, + intervalMin: config.heartbeat.interval ? parseInt(config.heartbeat.interval) : undefined, + }, + }, + }; + + // Add BYOK providers if configured + if (config.providers && config.providers.length > 0) { + yamlConfig.providers = config.providers.map(p => ({ + id: p.id, + name: p.name, + type: p.id, // id is the type (anthropic, openai, etc.) + apiKey: p.apiKey, + })); + } + + // Save YAML config + const configPath = resolve(process.cwd(), 'lettabot.yaml'); + saveConfig(yamlConfig, configPath); + p.log.success('Configuration saved to lettabot.yaml'); + + // Sync BYOK providers to Letta Cloud + if (yamlConfig.providers && yamlConfig.providers.length > 0 && yamlConfig.server.mode === 'cloud') { + const spinner = p.spinner(); + spinner.start('Syncing BYOK providers to Letta Cloud...'); + try { + await syncProviders(yamlConfig); + spinner.stop('BYOK providers synced'); + } catch (err) { + spinner.stop('Failed to sync providers (will retry on startup)'); + } + } + + // Also save .env for backwards compatibility saveEnv(env); - p.log.success('Configuration saved to .env'); // Save agent ID with server URL if (config.agentId) { diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index d39a6e8..d44c140 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -174,8 +174,8 @@ export async function buildModelOptions(options?: { // Add custom option result.push({ value: '__custom__', - label: 'Custom model', - hint: 'Enter handle: provider/model-name' + label: 'Other (specify handle)', + hint: 'e.g. anthropic/claude-sonnet-4-5-20250929' }); return result; @@ -206,8 +206,8 @@ async function buildServerModelOptions(): Promise Date: Wed, 28 Jan 2026 23:07:15 -0800 Subject: [PATCH 19/25] Stop writing to .env, use lettabot.yaml only --- src/onboard.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index 85f0367..9099443 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -1126,9 +1126,6 @@ export async function onboard(): Promise { } } - // Also save .env for backwards compatibility - saveEnv(env); - // Save agent ID with server URL if (config.agentId) { const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL || 'https://api.letta.com'; From b97b28746271f42b9e0a64a4978f8701be6ecb11 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 23:10:47 -0800 Subject: [PATCH 20/25] YAML config takes priority over .env --- src/config/io.ts | 7 +++---- src/main.ts | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/config/io.ts b/src/config/io.ts index 1b6fe79..045000e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -143,14 +143,13 @@ export function configToEnv(config: LettaBotConfig): Record { } /** - * Apply config to process.env + * Apply config to process.env (YAML config takes priority over .env) */ export function applyConfigToEnv(config: LettaBotConfig): void { const env = configToEnv(config); for (const [key, value] of Object.entries(env)) { - if (!process.env[key]) { - process.env[key] = value; - } + // YAML config always takes priority + process.env[key] = value; } } diff --git a/src/main.ts b/src/main.ts index 9c92d55..cf61151 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,13 +5,15 @@ * Chat continues seamlessly between Telegram, Slack, and WhatsApp. */ +// Load .env first for backwards compatibility import 'dotenv/config'; + import { createServer } from 'node:http'; import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawn } from 'node:child_process'; -// Load YAML config and apply to process.env (before other imports) +// Load YAML config and apply to process.env (overrides .env values) import { loadConfig, applyConfigToEnv, syncProviders, resolveConfigPath } from './config/index.js'; const yamlConfig = loadConfig(); console.log(`[Config] Loaded from ${resolveConfigPath()}`); From 7a0be1cb332d2c237d49f5c3c84841175d3247eb Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 23:16:29 -0800 Subject: [PATCH 21/25] Remove all .env usage, use lettabot.yaml only --- src/cli.ts | 190 ++++++++------------------------------------- src/cli/message.ts | 5 +- src/main.ts | 3 - src/onboard.ts | 62 +-------------- 4 files changed, 40 insertions(+), 220 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 50f9611..f2d7a67 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,10 @@ * lettabot configure - Configure settings */ -import 'dotenv/config'; +// Config loaded from lettabot.yaml +import { loadConfig, applyConfigToEnv } from './config/index.js'; +const config = loadConfig(); +applyConfigToEnv(config); import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; @@ -18,90 +21,30 @@ const args = process.argv.slice(2); const command = args[0]; const subCommand = args[1]; -const ENV_PATH = resolve(process.cwd(), '.env'); -const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example'); - // Check if value is a placeholder const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val); -// Simple prompt helper -function prompt(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -// Load current env values -function loadEnv(): Record { - const env: Record = {}; - if (existsSync(ENV_PATH)) { - const content = readFileSync(ENV_PATH, 'utf-8'); - for (const line of content.split('\n')) { - if (line.startsWith('#') || !line.includes('=')) continue; - const [key, ...valueParts] = line.split('='); - env[key.trim()] = valueParts.join('=').trim(); - } - } - return env; -} - -// Save env values -function saveEnv(env: Record): void { - // Start with example if no .env exists - let content = ''; - if (existsSync(ENV_EXAMPLE_PATH)) { - content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8'); - } - - // Update values - for (const [key, value] of Object.entries(env)) { - const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm'); - if (regex.test(content)) { - content = content.replace(regex, `${key}=${value}`); - } else { - content += `\n${key}=${value}`; - } - } - - writeFileSync(ENV_PATH, content); -} - // Import onboard from separate module import { onboard } from './onboard.js'; async function configure() { const p = await import('@clack/prompts'); + const { resolveConfigPath } = await import('./config/index.js'); p.intro('🤖 LettaBot Configuration'); - const env = loadEnv(); - - // Check both .env file and shell environment, filtering placeholders - const checkVar = (key: string) => { - const fileValue = env[key]; - const envValue = process.env[key]; - const value = fileValue || envValue; - return isPlaceholder(value) ? undefined : value; - }; - + // Show current config from YAML const configRows = [ - ['LETTA_API_KEY', checkVar('LETTA_API_KEY') ? '✓ Set' : '✗ Not set'], - ['TELEGRAM_BOT_TOKEN', checkVar('TELEGRAM_BOT_TOKEN') ? '✓ Set' : '✗ Not set'], - ['SLACK_BOT_TOKEN', checkVar('SLACK_BOT_TOKEN') ? '✓ Set' : '✗ Not set'], - ['SLACK_APP_TOKEN', checkVar('SLACK_APP_TOKEN') ? '✓ Set' : '✗ Not set'], - ['HEARTBEAT_INTERVAL_MIN', checkVar('HEARTBEAT_INTERVAL_MIN') || 'Not set'], - ['CRON_ENABLED', checkVar('CRON_ENABLED') || 'false'], - ['WORKING_DIR', checkVar('WORKING_DIR') || '/tmp/lettabot'], - ['AGENT_NAME', checkVar('AGENT_NAME') || 'LettaBot'], - ['MODEL', checkVar('MODEL') || '(default)'], + ['Server Mode', config.server.mode], + ['API Key', config.server.apiKey ? '✓ Set' : '✗ Not set'], + ['Agent Name', config.agent.name], + ['Model', config.agent.model], + ['Telegram', config.channels.telegram?.enabled ? '✓ Enabled' : '✗ Disabled'], + ['Slack', config.channels.slack?.enabled ? '✓ Enabled' : '✗ Disabled'], + ['Cron', config.features?.cron ? '✓ Enabled' : '✗ Disabled'], + ['Heartbeat', config.features?.heartbeat?.enabled ? `✓ ${config.features.heartbeat.intervalMin}min` : '✗ Disabled'], + ['BYOK Providers', config.providers?.length ? config.providers.map(p => p.name).join(', ') : 'None'], ]; const maxKeyLength = Math.max(...configRows.map(([key]) => key.length)); @@ -109,20 +52,14 @@ async function configure() { .map(([key, value]) => `${(key + ':').padEnd(maxKeyLength + 1)} ${value}`) .join('\n'); - p.note(summary, 'Current Configuration'); + p.note(summary, `Current Configuration (${resolveConfigPath()})`); const choice = await p.select({ - message: 'What would you like to configure?', + message: 'What would you like to do?', options: [ - { value: '1', label: 'Letta API Key', hint: '' }, - { value: '2', label: 'Telegram', hint: '' }, - { value: '3', label: 'Slack', hint: '' }, - { value: '4', label: 'Heartbeat', hint: '' }, - { value: '5', label: 'Cron', hint: '' }, - { value: '6', label: 'Working Directory', hint: '' }, - { value: '7', label: 'Agent Name & Model', hint: '' }, - { value: '8', label: 'Edit .env directly', hint: '' }, - { value: '9', label: 'Exit', hint: '' }, + { value: 'onboard', label: 'Run setup wizard', hint: 'lettabot onboard' }, + { value: 'edit', label: 'Edit config file', hint: resolveConfigPath() }, + { value: 'exit', label: 'Exit', hint: '' }, ], }); @@ -132,89 +69,28 @@ async function configure() { } switch (choice) { - case '1': - env.LETTA_API_KEY = await prompt('Enter Letta API Key: '); - saveEnv(env); - console.log('✓ Saved'); + case 'onboard': + await onboard(); break; - case '2': - env.TELEGRAM_BOT_TOKEN = await prompt('Enter Telegram Bot Token: '); - saveEnv(env); - console.log('✓ Saved'); - break; - case '3': - env.SLACK_BOT_TOKEN = await prompt('Enter Slack Bot Token: '); - env.SLACK_APP_TOKEN = await prompt('Enter Slack App Token: '); - saveEnv(env); - console.log('✓ Saved'); - break; - case '4': - env.HEARTBEAT_INTERVAL_MIN = await prompt('Heartbeat interval (minutes, 0 to disable): '); - saveEnv(env); - console.log('✓ Saved'); - break; - case '5': - env.CRON_ENABLED = (await prompt('Enable cron? (y/n): ')).toLowerCase() === 'y' ? 'true' : 'false'; - saveEnv(env); - console.log('✓ Saved'); - break; - case '6': - env.WORKING_DIR = await prompt('Working directory: '); - saveEnv(env); - console.log('✓ Saved'); - break; - case '7': { - const p = await import('@clack/prompts'); - const { buildModelOptions, handleModelSelection } = await import('./utils/model-selection.js'); - - const currentName = env.AGENT_NAME || 'LettaBot'; - const name = await p.text({ - message: 'Agent name', - placeholder: currentName, - initialValue: currentName, - }); - if (!p.isCancel(name) && name) env.AGENT_NAME = name; - - const currentModel = env.MODEL || 'default'; - p.log.info(`Current model: ${currentModel}\n`); - - const spinner = p.spinner(); - spinner.start('Fetching available models...'); - const modelOptions = await buildModelOptions(); - spinner.stop('Models loaded'); - - const modelChoice = await p.select({ - message: 'Select model', - options: modelOptions, - maxItems: 10, - }); - - if (!p.isCancel(modelChoice)) { - const selectedModel = await handleModelSelection(modelChoice, p.text); - if (selectedModel) { - env.MODEL = selectedModel; - } - } - - saveEnv(env); - p.log.success('Saved'); + case 'edit': { + const configPath = resolveConfigPath(); + const editor = process.env.EDITOR || 'nano'; + console.log(`Opening ${configPath} in ${editor}...`); + spawnSync(editor, [configPath], { stdio: 'inherit' }); break; } - case '8': - const editor = process.env.EDITOR || 'nano'; - spawnSync(editor, [ENV_PATH], { stdio: 'inherit' }); + case 'exit': break; - case '9': - return; - default: - console.log('Invalid choice'); } } async function server() { + const { resolveConfigPath } = await import('./config/index.js'); + const configPath = resolveConfigPath(); + // Check if configured - if (!existsSync(ENV_PATH)) { - console.log('No .env found. Run "lettabot onboard" first.\n'); + if (!existsSync(configPath)) { + console.log(`No config found at ${configPath}. Run "lettabot onboard" first.\n`); process.exit(1); } diff --git a/src/cli/message.ts b/src/cli/message.ts index 7294f40..f29e82e 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -10,7 +10,10 @@ * (heartbeats, cron jobs) or to send to different channels during conversations. */ -import 'dotenv/config'; +// Config loaded from lettabot.yaml +import { loadConfig, applyConfigToEnv } from '../config/index.js'; +const config = loadConfig(); +applyConfigToEnv(config); import { resolve } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; diff --git a/src/main.ts b/src/main.ts index cf61151..662fb9a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,9 +5,6 @@ * Chat continues seamlessly between Telegram, Slack, and WhatsApp. */ -// Load .env first for backwards compatibility -import 'dotenv/config'; - import { createServer } from 'node:http'; import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; diff --git a/src/onboard.ts b/src/onboard.ts index 9099443..e9de2a6 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -9,9 +9,6 @@ import * as p from '@clack/prompts'; import { saveConfig, syncProviders } from './config/index.js'; import type { LettaBotConfig, ProviderConfig } from './config/types.js'; -const ENV_PATH = resolve(process.cwd(), '.env'); -const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example'); - // ============================================================================ // Config Types // ============================================================================ @@ -46,60 +43,6 @@ interface OnboardConfig { cron: boolean; } -// ============================================================================ -// Env Helpers -// ============================================================================ - -function loadEnv(): Record { - const env: Record = {}; - if (existsSync(ENV_PATH)) { - const content = readFileSync(ENV_PATH, 'utf-8'); - for (const line of content.split('\n')) { - if (line.startsWith('#') || !line.includes('=')) continue; - const [key, ...valueParts] = line.split('='); - env[key.trim()] = valueParts.join('=').trim(); - } - } - return env; -} - -function saveEnv(env: Record): void { - // Start with .env.example as template, fall back to existing .env if example doesn't exist - let content = ''; - if (existsSync(ENV_EXAMPLE_PATH)) { - content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8'); - } else if (existsSync(ENV_PATH)) { - content = readFileSync(ENV_PATH, 'utf-8'); - } - - // Track which keys we've seen in the template to detect deletions - const keysInTemplate = new Set(); - for (const line of content.split('\n')) { - const match = line.match(/^#?\s*(\w+)=/); - if (match) keysInTemplate.add(match[1]); - } - - // Update or add keys that exist in env - for (const [key, value] of Object.entries(env)) { - const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm'); - if (regex.test(content)) { - content = content.replace(regex, `${key}=${value}`); - } else { - content += `\n${key}=${value}`; - } - } - - // Comment out keys that were in template but deleted from env - for (const key of keysInTemplate) { - if (!(key in env)) { - const regex = new RegExp(`^(${key}=.*)$`, 'm'); - content = content.replace(regex, '# $1'); - } - } - - writeFileSync(ENV_PATH, content); -} - const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val); // ============================================================================ @@ -859,12 +802,13 @@ async function reviewLoop(config: OnboardConfig, env: Record): P // ============================================================================ export async function onboard(): Promise { - const env = loadEnv(); + // Temporary storage for wizard values (no longer uses .env) + const env: Record = {}; p.intro('🤖 LettaBot Setup'); // Show server info - const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL || 'https://api.letta.com'; + const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; const isLocal = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1'); p.note(`${baseUrl}\n${isLocal ? 'Local Docker' : 'Letta Cloud'}`, 'Server'); From 074bc49a9c75dcaec33efab4c18c6fd4e9a8394f Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 23:20:27 -0800 Subject: [PATCH 22/25] Fix: Check for lettabot.yaml instead of .env on startup --- src/main.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main.ts b/src/main.ts index 662fb9a..2a42f57 100644 --- a/src/main.ts +++ b/src/main.ts @@ -119,13 +119,11 @@ import { PollingService } from './polling/service.js'; import { agentExists } from './tools/letta-api.js'; import { installSkillsToWorkingDir } from './skills/loader.js'; -// Check if setup is needed -const ENV_PATH = resolve(process.cwd(), '.env'); -if (!existsSync(ENV_PATH)) { - console.log('\n No .env file found. Running setup wizard...\n'); - const setupPath = new URL('./setup.ts', import.meta.url).pathname; - spawn('npx', ['tsx', setupPath], { stdio: 'inherit', cwd: process.cwd() }); - process.exit(0); +// Check if config exists +const configPath = resolveConfigPath(); +if (!existsSync(configPath)) { + console.log(`\n No config found at ${configPath}. Run "lettabot onboard" first.\n`); + process.exit(1); } // Parse heartbeat target (format: "telegram:123456789" or "slack:C1234567890") From 2bcfda46c5515c1358a58fa2f2bcc902c6772798 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 23:30:43 -0800 Subject: [PATCH 23/25] Add debug logging for session init, increase timeout to 30s --- src/core/bot.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index 02fb6d6..aa4ad56 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -211,7 +211,11 @@ export class LettaBot { } // Initialize session explicitly (so we can log timing/failures) - const initTimeoutMs = 15000; + console.log('[Bot] About to initialize session...'); + console.log('[Bot] LETTA_API_KEY in env:', process.env.LETTA_API_KEY ? `${process.env.LETTA_API_KEY.slice(0, 30)}...` : 'NOT SET'); + console.log('[Bot] LETTA_CLI_PATH:', process.env.LETTA_CLI_PATH || 'not set (will use default)'); + + const initTimeoutMs = 30000; // Increased to 30s const withTimeout = async (promise: Promise, label: string): Promise => { let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_, reject) => { From 7c573b479ee9d30d2c5683c2913aaf28349ecf5e Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 23:37:24 -0800 Subject: [PATCH 24/25] Pre-populate onboard wizard from existing lettabot.yaml --- src/onboard.ts | 64 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index e9de2a6..51087df 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -802,13 +802,23 @@ async function reviewLoop(config: OnboardConfig, env: Record): P // ============================================================================ export async function onboard(): Promise { - // Temporary storage for wizard values (no longer uses .env) + // Temporary storage for wizard values const env: Record = {}; + // Load existing config if available + const { loadConfig, resolveConfigPath } = await import('./config/index.js'); + const existingConfig = loadConfig(); + const configPath = resolveConfigPath(); + const hasExistingConfig = existsSync(configPath); + p.intro('🤖 LettaBot Setup'); - // Show server info - const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com'; + if (hasExistingConfig) { + p.log.info(`Loading existing config from ${configPath}`); + } + + // Pre-populate from existing config + const baseUrl = existingConfig.server.baseUrl || process.env.LETTA_BASE_URL || 'https://api.letta.com'; const isLocal = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1'); p.note(`${baseUrl}\n${isLocal ? 'Local Docker' : 'Letta Cloud'}`, 'Server'); @@ -834,34 +844,44 @@ export async function onboard(): Promise { } // Initialize config from existing env + // Pre-populate from existing YAML config const config: OnboardConfig = { - authMethod: 'skip', + authMethod: hasExistingConfig ? 'keep' : 'skip', + apiKey: existingConfig.server.apiKey, + baseUrl: existingConfig.server.baseUrl, telegram: { - enabled: !!env.TELEGRAM_BOT_TOKEN && !isPlaceholder(env.TELEGRAM_BOT_TOKEN), - token: isPlaceholder(env.TELEGRAM_BOT_TOKEN) ? undefined : env.TELEGRAM_BOT_TOKEN, + enabled: existingConfig.channels.telegram?.enabled || false, + token: existingConfig.channels.telegram?.token, + dmPolicy: existingConfig.channels.telegram?.dmPolicy, + allowedUsers: existingConfig.channels.telegram?.allowedUsers?.map(String), }, slack: { - enabled: !!env.SLACK_BOT_TOKEN, - appToken: env.SLACK_APP_TOKEN, - botToken: env.SLACK_BOT_TOKEN, + enabled: existingConfig.channels.slack?.enabled || false, + appToken: existingConfig.channels.slack?.appToken, + botToken: existingConfig.channels.slack?.botToken, + allowedUsers: existingConfig.channels.slack?.allowedUsers, }, whatsapp: { - enabled: env.WHATSAPP_ENABLED === 'true', - selfChat: env.WHATSAPP_SELF_CHAT_MODE === 'true', + enabled: existingConfig.channels.whatsapp?.enabled || false, + selfChat: existingConfig.channels.whatsapp?.selfChat, + dmPolicy: existingConfig.channels.whatsapp?.dmPolicy, }, signal: { - enabled: !!env.SIGNAL_PHONE_NUMBER, - phone: env.SIGNAL_PHONE_NUMBER, + enabled: existingConfig.channels.signal?.enabled || false, + phone: existingConfig.channels.signal?.phone, + dmPolicy: existingConfig.channels.signal?.dmPolicy, }, gmail: { enabled: false }, heartbeat: { - enabled: !!env.HEARTBEAT_INTERVAL_MIN, - interval: env.HEARTBEAT_INTERVAL_MIN, + enabled: existingConfig.features?.heartbeat?.enabled || false, + interval: existingConfig.features?.heartbeat?.intervalMin?.toString(), }, - cron: env.CRON_ENABLED === 'true', - agentChoice: 'skip', - agentName: env.AGENT_NAME, - model: env.MODEL, + cron: existingConfig.features?.cron || false, + agentChoice: hasExistingConfig ? 'env' : 'skip', + agentName: existingConfig.agent.name, + agentId: existingConfig.agent.id, + model: existingConfig.agent.model, + providers: existingConfig.providers?.map(p => ({ id: p.id, name: p.name, apiKey: p.apiKey })), }; // Run through all steps @@ -1053,9 +1073,9 @@ export async function onboard(): Promise { })); } - // Save YAML config - const configPath = resolve(process.cwd(), 'lettabot.yaml'); - saveConfig(yamlConfig, configPath); + // Save YAML config (use project-local path) + const savePath = resolve(process.cwd(), 'lettabot.yaml'); + saveConfig(yamlConfig, savePath); p.log.success('Configuration saved to lettabot.yaml'); // Sync BYOK providers to Letta Cloud From 49d75ead9a76a5e586c03ca4a1aa5125a9c29900 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 23:41:46 -0800 Subject: [PATCH 25/25] Cache API key and other config values in onboard wizard --- src/onboard.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/onboard.ts b/src/onboard.ts index 51087df..0b1f115 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -53,11 +53,12 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro const { requestDeviceCode, pollForToken, LETTA_CLOUD_API_URL } = await import('./auth/oauth.js'); const { saveTokens, loadTokens, getOrCreateDeviceId, getDeviceName } = await import('./auth/tokens.js'); - const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL; + const baseUrl = config.baseUrl || env.LETTA_BASE_URL || process.env.LETTA_BASE_URL; const isLettaCloud = !baseUrl || baseUrl === LETTA_CLOUD_API_URL || baseUrl === 'https://api.letta.com'; const existingTokens = loadTokens(); - const realApiKey = isPlaceholder(env.LETTA_API_KEY) ? undefined : env.LETTA_API_KEY; + // Check both env and config for existing API key + const realApiKey = config.apiKey || (isPlaceholder(env.LETTA_API_KEY) ? undefined : env.LETTA_API_KEY); const validOAuthToken = isLettaCloud ? existingTokens?.accessToken : undefined; const hasExistingAuth = !!realApiKey || !!validOAuthToken; const displayKey = realApiKey || validOAuthToken; @@ -149,7 +150,7 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro const serverUrl = await p.text({ message: 'Letta server URL', placeholder: 'http://localhost:8283', - initialValue: 'http://localhost:8283', + initialValue: config.baseUrl || 'http://localhost:8283', }); if (p.isCancel(serverUrl)) { p.cancel('Setup cancelled'); process.exit(0); }