From 4dfc35326a2691df98fa97f5a8276ae04d9bd75c Mon Sep 17 00:00:00 2001 From: Juniper Bevensee Date: Sat, 18 Jul 2026 11:18:26 +1000 Subject: [PATCH 1/3] spike(letta): Letta server-as-container + REST agent layer + read-only view (Path 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof (spike, do not merge) that HSM can run a Letta *server* as a managed container via the existing runtime-neutral DockerService, and CRUD/message individual Letta agents over REST as a separate "agents-as-API-resources" layer distinct from the container model. - docker/letta-compose.yml: letta/letta server, :8283, embedded Postgres volume (commented shared-pgvector alt). Managed by DockerService unchanged. - lib/services/letta.ts: minimal fetch REST client — listAgents, getAgent, createAgent, sendMessage, getMemoryBlocks. Base URL from LETTA_BASE_URL, defaults http://localhost:8283. Loosely typed (spike). - app/api/letta/*: same-origin proxy routes (list / send message / blocks). - app/(dashboard)/letta: read-only agents view + single send-message action. - Sidebar: "Letta" nav entry. Out of scope: airlock, wizard integration, runtime-adapter refactor, no lib/types.ts changes on main. Typecheck + lint pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(dashboard)/letta/page.tsx | 199 ++++++++++++++++++++ app/api/letta/agents/[id]/blocks/route.ts | 19 ++ app/api/letta/agents/[id]/messages/route.ts | 24 +++ app/api/letta/agents/route.ts | 16 ++ components/shell/sidebar.tsx | 6 +- docker/letta-compose.yml | 75 ++++++++ lib/services/index.ts | 4 + lib/services/letta.ts | 175 +++++++++++++++++ 8 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 app/(dashboard)/letta/page.tsx create mode 100644 app/api/letta/agents/[id]/blocks/route.ts create mode 100644 app/api/letta/agents/[id]/messages/route.ts create mode 100644 app/api/letta/agents/route.ts create mode 100644 docker/letta-compose.yml create mode 100644 lib/services/letta.ts diff --git a/app/(dashboard)/letta/page.tsx b/app/(dashboard)/letta/page.tsx new file mode 100644 index 0000000..eaf55e0 --- /dev/null +++ b/app/(dashboard)/letta/page.tsx @@ -0,0 +1,199 @@ +'use client' + +/** + * SPIKE (Path 1) — read-only "Letta agents" view. See spec §4 steps 2–3. + * + * Proves the "agents-as-API-resources" shape distinct from HSM's container + * model: it lists agents from one Letta *server* (GET /v1/agents), lets you send + * one message (the single action), and peek at an agent's core-memory blocks. + * No create/delete UI, no per-agent lifecycle buttons — those don't exist for + * Letta agents (they're rows in Postgres, not containers). + */ + +import { useState } from 'react' +import { useApi } from '@/lib/hooks/use-api' +import { Button } from '@/components/ui/button' +import type { LettaAgent, LettaBlock, LettaMessage } from '@/lib/services/letta' +import { toast } from 'sonner' + +/** Best-effort stringify of a Letta message's `content` (spike-loose typing). */ +function renderContent(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((c) => (typeof c === 'string' ? c : (c as { text?: string })?.text ?? JSON.stringify(c))) + .join('') + } + return content == null ? '' : JSON.stringify(content) +} + +function AgentRow({ agent }: { agent: LettaAgent }) { + const [open, setOpen] = useState(false) + const [text, setText] = useState('') + const [sending, setSending] = useState(false) + const [reply, setReply] = useState(null) + const [blocks, setBlocks] = useState(null) + const [blocksLoading, setBlocksLoading] = useState(false) + + async function send() { + if (!text.trim()) return + setSending(true) + setReply(null) + try { + const res = await fetch(`/api/letta/agents/${agent.id}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: text.trim() }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.error ?? 'Send failed') + setReply(json.messages ?? []) + setText('') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Send failed') + } finally { + setSending(false) + } + } + + async function loadBlocks() { + setBlocksLoading(true) + try { + const res = await fetch(`/api/letta/agents/${agent.id}/blocks`) + const json = await res.json() + if (!res.ok) throw new Error(json?.error ?? 'Failed to read memory') + setBlocks(json) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to read memory') + } finally { + setBlocksLoading(false) + } + } + + return ( + <> + + + {agent.name ?? agent.id} + + {agent.id} + {agent.model ?? '—'} + + + + + {open && ( + + + {/* Single action: send a message */} +
+