#!/usr/bin/env bun /** * Web Chat Server * * A simple web UI for chatting with a Letta agent. * * Usage: * bun server.ts # Start server on port 3000 * bun server.ts --port=8080 # Custom port * * Requirements: * - LETTA_API_KEY environment variable (or logged in via `letta auth`) * - bun add @letta-ai/letta-client (for memory reading) */ import { readFile, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { createSession, resumeSession, type Session } from '../../src/index.js'; import Letta from '@letta-ai/letta-client'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const STATE_FILE = join(__dirname, 'state.json'); const HTML_FILE = join(__dirname, 'index.html'); interface AppState { agentId: string | null; } let session: Session | null = null; let state: AppState = { agentId: null }; // Letta client for memory operations const lettaClient = new Letta({ baseUrl: process.env.LETTA_BASE_URL || 'https://api.letta.com', apiKey: process.env.LETTA_API_KEY, }); // Load state async function loadState(): Promise { if (existsSync(STATE_FILE)) { state = JSON.parse(await readFile(STATE_FILE, 'utf-8')); } } // Save state async function saveState(): Promise { await writeFile(STATE_FILE, JSON.stringify(state, null, 2)); } // Get or create session async function getSession(): Promise { if (session) return session; if (state.agentId) { console.log(`Resuming agent: ${state.agentId}`); session = await resumeSession(state.agentId, { model: 'haiku', permissionMode: 'bypassPermissions', }); } else { console.log('Creating new agent...'); session = await createSession({ model: 'haiku', systemPrompt: `You are a helpful assistant accessible through a web interface. Be concise but friendly. You can help with: - Answering questions - Writing and reviewing code - Brainstorming ideas - General conversation You have memory that persists across conversations. Use it to remember important context about the user and ongoing topics.`, memory: [ { label: 'user-context', value: '# User Context\n\n(Nothing learned yet)', description: 'What I know about the user', }, { label: 'conversation-notes', value: '# Conversation Notes\n\n(No notes yet)', description: 'Important things from our conversations', }, ], permissionMode: 'bypassPermissions', }); } return session; } // Parse args const { values } = parseArgs({ args: process.argv.slice(2), options: { port: { type: 'string', default: '3000' }, }, }); const PORT = parseInt(values.port!, 10); // Load state on startup await loadState(); console.log(`Starting web chat server on http://localhost:${PORT}`); // Start server Bun.serve({ port: PORT, idleTimeout: 120, // 2 minutes for slow LLM responses async fetch(req) { const url = new URL(req.url); // Serve HTML if (url.pathname === '/' || url.pathname === '/index.html') { const html = await readFile(HTML_FILE, 'utf-8'); return new Response(html, { headers: { 'Content-Type': 'text/html' }, }); } // API: Get status if (url.pathname === '/api/status' && req.method === 'GET') { return Response.json({ agentId: state.agentId, connected: session !== null, }); } // API: Chat (streaming) if (url.pathname === '/api/chat' && req.method === 'POST') { const { message } = await req.json(); if (!message) { return Response.json({ error: 'Message required' }, { status: 400 }); } const sess = await getSession(); // Save agent ID after first message if (!state.agentId && sess.agentId) { state.agentId = sess.agentId; await saveState(); console.log(`Agent created: ${state.agentId}`); } // Stream response const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); }; try { await sess.send(message); for await (const msg of sess.stream()) { if (msg.type === 'assistant') { send({ type: 'text', content: msg.content }); } else if (msg.type === 'tool_call' && 'toolName' in msg) { send({ type: 'tool', name: msg.toolName }); } else if (msg.type === 'result') { // Update agent ID if we got it if (!state.agentId && sess.agentId) { state.agentId = sess.agentId; await saveState(); } } } send({ type: 'done' }); controller.close(); } catch (err) { send({ type: 'error', message: String(err) }); controller.close(); } }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); } // API: Get memory if (url.pathname === '/api/memory' && req.method === 'GET') { if (!state.agentId) { return Response.json({ blocks: [] }); } try { const response = await lettaClient.agents.blocks.list(state.agentId); // Response is paginated - blocks are in .body or we iterate const blockList = Array.isArray(response) ? response : (response.body || []); return Response.json({ blocks: blockList.map((b: any) => ({ label: b.label, value: b.value, description: b.description, })) }); } catch (err) { console.error('Failed to read memory:', err); return Response.json({ blocks: [], error: String(err) }); } } // API: Update memory if (url.pathname === '/api/memory' && req.method === 'POST') { if (!state.agentId) { return Response.json({ error: 'No agent' }, { status: 400 }); } const { label, value } = await req.json(); if (!label || value === undefined) { return Response.json({ error: 'label and value required' }, { status: 400 }); } try { await lettaClient.agents.blocks.update(state.agentId, label, { value }); return Response.json({ ok: true }); } catch (err) { console.error('Failed to update memory:', err); return Response.json({ error: String(err) }, { status: 500 }); } } // API: Reset if (url.pathname === '/api/reset' && req.method === 'POST') { if (session) { session.close(); session = null; } state = { agentId: null }; await saveState(); return Response.json({ ok: true }); } return new Response('Not Found', { status: 404 }); }, });