Architecture
Three processes, one protocol. Claude Code talks to the engine over MCP; the engine talks to the browser window over loopback HTTP; the window is a static unique with the shell mounted on top.
┌──────────────┐ MCP (stdio) ┌───────────────────────┐ SSE ↓ / POST ↑ ┌──────────────────┐
│ Claude Code │ ◄─────────────► │ lets engine (Node) │ ◄────────────────► │ Browser window │
│ /lets skill │ 10 tools │ host · bus · entity │ loopback + token │ unique + shell │
│ prompt hook │ ──GET /drain──► │ router · store │ │ companion │
└──────────────┘ └───────────────────────┘ └──────────────────┘
│
~/.lets/ (state)
Pieces
engine/src/mcp.mjs
JSON-RPC 2.0 over newline-delimited stdio: initialize, tools/list, tools/call, ping. No SDK, so the plugin installs with zero npm install.
engine/src/host.mjs
An http.Server bound to 127.0.0.1 on a free port. Routes: /u/<unique>/… (static, CSP), /sdk/…, /uploads/…, /lip/stream (SSE down), /lip/event (POST up), /lip/upload (blobs → disk → media.* event with a path), /lip/drain (named cursor read for hooks), /lip/persona, /lip/entity (the companion's full state for den, token-protected; new in 0.6.0), and /lip/run/info, /lip/run/consent, /lip/run (the runner, below). A run file ~/.lets/run/host-<pid>.json publishes port and token for the hook.
The runner new in 0.7.0 runs the program a window holds in a sandboxed subprocess in a throwaway job folder (Python under an audit hook, JavaScript and TypeScript under Node's permission model), only after the human allowed that unique on this machine, and streams the output back as NDJSON; Claude gets a matching run tool.
engine/src/bus.mjs
One ring buffer of up-events with named cursors (claude, hook, ad-hoc query cursors), long-poll waiters, per-window SSE client sets with pending queues (commands sent before the window connects are delivered on attach, unless their ttl expired), ack tracking, and coalescing of chatty types (graph.changed, entity.profile) that no reader has seen.
engine/src/registry.mjs
Discovers uniques in uniques/ (bundled, empty), ~/.lets/uniques (defaults and store installs), LETS_UNIQUES_DIRS (extra folders) and <project>/.lets/uniques (local); validates unique.json against protocol/schemas/unique.schema.json plus the safety rules (entry inside the folder, no reserved domains, declared domains only).
engine/src/defaults.mjs
The uniques every installation gets (defaults.json: graph and den today). Missing ones are cloned into ~/.lets/uniques in the background when the engine starts, on demand when open names one, by lets defaults, and by install.mjs. Trusted by the list, still validated.
engine/src/entity.mjs
Persona, profile, memory and skills on disk. brief() is what Claude sees on every open (kept small on purpose). absorbProfile() folds the shell's aggregate stats into the learned profile and nudges the persona's traits. teach() writes a special skill. Since 0.6.0 the entity tool can also set the persona directly, unlearn likes, dislikes, nicknames and memory lines, list memory, return the full state and reset the persona to its defaults: what den uses to apply your edits.
engine/src/sessions.mjs new in 0.4.0
Handoff. When a unique's export event arrives ("Send to Claude"), the payload is saved under ~/.lets/sessions/<unique>/, the event is marked data.session = {id, handoff:true} and the window gets session.close; Claude answers in the transcript. open {resume} queues the unique's restore command with the saved payload, so the board comes back. Which types play which role comes from the manifest's session block (<domain>.export / .snapshot / .load by default).
engine/src/router.mjs
Kind priors → complexity and risk → tier; a ledger of outcomes per (kind, tier) bumps tiers with poor trust and allows safe demotions; plan() builds topological waves for parallel Agent spawns.
engine/src/store.mjs
Git stores (index.json) → inspect (clone to staging, validate, hash check, executable scan) → install (copy to ~/.lets/uniques/<name>, record the sha). Only after a human confirms.
sdk/lets-client.js · sdk/lets-shell.js
The browser transport, and the shell: the companion, chat, components, media and toasts.
hooks/drain.mjs
UserPromptSubmit: pulls the hook cursor from every live host and prints the events as context. Window events therefore reach Claude with your next message at zero tool-call cost.
Why these choices
- A browser window, not a native pane
- The Claude Code mod API draws a constrained element tree; a graph board with a rolling companion, clipboard paste and drag needs a real DOM. The app's built-in browser pane makes it feel native anyway.
- SSE + POST, not WebSocket
- Zero dependencies, trivially debuggable, works through any proxy, and the engine must install from git with no build.
- Events are semantic
- Pointer moves never cross to the model; the shell turns behaviour into aggregates (
entity.profile). Tokens are spent on meaning. - Static uniques
- Community mediums are HTML/JS served under CSP from loopback with a per-host token. The host runs none of their code (the runner executes the program a window holds, never the unique itself). The manifest declares domains, capabilities and permissions up front, and the store pins commits and manifest hashes.
- The guide is data
- Each unique ships
GUIDE.md; the engine hands it to Claude onopen. New mediums teach Claude how to behave without changing the engine or the skill. - The companion is persistent and honest
- Learning is explicit (
learn,teach,reinforce) and inspectable on disk; nothing is "learned" that the model did not write down.
Lifecycle of /lets graph
- The skill calls
open. The engine registers a window id, mints the URL (/u/graph/#w=…&t=…) and returns the guide and the entity brief. - Claude opens the URL in the browser pane. The page loads
lets-client.js, connects SSE, receiveslip.helloandentity.persona, and emitslip.ready. - The human draws. The board emits
graph.*; the shell emitsentity.*,chat.*,component.*,media.*. - On the human's next prompt the hook drains those events into context, or Claude calls
listen/queryexplicitly. - Claude reacts with
send/say: nodes appear animated, the companion speaks, a component card shows up. - Send to Claude new in 0.4.0: the host saves the board as a session and closes the window; Claude reviews it in the transcript and labels the session.
/lets graphwith "continue …" resumes it (open {resume}). - Before leaving, Claude writes what it learned (
entity learn/teach).
Future mediums
A unique may be anything the LIP envelope can carry. A voice ring would emit chat.message from transcription and entity.summon on a wake word; an AR overlay would emit component.action from gestures. The shell contract (entity, chat, components, media) is the stable surface; the engine, the skill and the store need no change for a new medium.