Table of Contents
- Architecture
- Layout of src/
- The engine loop (src/session/engine.ts)
- Permission evaluation (src/permission/evaluate.ts)
- Providers (src/provider/)
- The session store and turns
- The system prompt (src/prompt/assemble.ts)
- The TUI (src/tui/)
- ACP and the link (src/acp/)
- Updates and signing (src/update/)
- The service (src/service.ts)
- Library, memory, skills, MCP, voice
Architecture
One process per front end, one event bus per session. createApp (in
src/app.ts) wires configuration, the project, the model and an Engine; the
TUI, lembas run, the ACP agent and the tests all start there and subscribe to
the same events.
Layout of src/
cli.ts argv: the TUI, or run | models | sessions | config (check|schema|list|get|set)
| login | logout | serve --stdio | service | mcp | voice | kb | trust | update
| uninstall
app.ts wiring shared by every front end: config → project → model → Engine; spawn
(subagents), the live Settings, freeze() of memory and skills per session
headless.ts `lembas run "…"`: answer on stdout, tool lines on stderr, or --json lines
settings.ts a setting's value and source; set for session / global / project
reload.ts /reload: execv the binary on disk now, back on the same session
doctor.ts `lembas config check`: machine checks (another `lembas` first on PATH, …)
service.ts `lembas service`: the link as a systemd *user* unit, one copy at a time
uninstall.ts `lembas uninstall [--purge]`
harness.ts the harness spec embedded at build time: version, tool catalogue
version.ts replaced at compile time
bus/ the typed event bus, and the Asker interface (who answers approvals,
questions and plans)
config/ XDG paths (LEMBAS_HOME for tests), YAML loading and layering, {env:}/{file:}
substitution and key_cmd, the zod schema, the settings list, JSON Schemas
provider/ types.ts (Message, StreamEvent), common.ts (retry rule, part builder, stream
recorder), sse.ts, think.ts (inline <think> splitter), effort.ts, learned.ts,
discover.ts (context windows), http.ts (TLS, auth), tokens.ts, refs.ts (how a
model is named); one client per dialect: openai-chat.ts, responses.ts,
anthropic.ts, gemini.ts, ollama.ts
permission/ wildcard.ts, arity.ts (OpenCode), bash.ts (the small shell reader),
hardline.ts (the floor), evaluate.ts (rules, modes, paths)
tool/ tool.ts (the framework), registry.ts, names.ts (former names); read, write,
edit, multiedit, apply_patch (+ patch.ts, unidiff.ts, replace.ts), search.ts
(list, glob, grep), bash + jobs, web, question (ask_user), plan_exit
(plan_submit), todo, task, memory, skills, library, session_search,
settings, view_image, project_tools (tasks, decisions)
session/ engine.ts (the loop), store.ts (SQLite + FTS5), turns.ts (undo/redo),
title.ts, context.ts (/context), capacity.ts (the capacity rule),
commands.ts (slash commands shared by the TUI and ACP)
prompt/ assemble.ts (the system prompt), personality.ts
project/ root.ts (.agent, or .lembas), init.ts (first open: trust, git, skeleton),
safe.ts (no writes through a committed symlink), attach.ts (@ references),
files.ts (the @ index), commands.ts, agents.ts, board.ts, image.ts
git/ run.ts, repo.ts, snapshot.ts (the shadow store), branch.ts, commit.ts,
release.ts, worktree.ts
memory/ store.ts (MEMORY.md, USER.md), threats.ts (the injection scanner)
skill/ SKILL.md directories, the skills block of the prompt
library/ store.ts (notes, bases, documents, chunks; FTS5 + vectors), chunks.ts,
embed.ts, ingest.ts, cli.ts (`lembas kb`)
mcp/ index.ts (McpManager: stdio and HTTP servers), oauth.ts
search/ webui (a LLeMbas instance's), searxng, firecrawl, ddg; fetch.ts (pages)
voice/ audio.ts (recorders and players), speech.ts, text.ts, index.ts
lembas/ a LLeMbas instance: client.ts (discovery, device login), login.ts, webui.ts
(the `webui` connection), personal.ts (personalization, library, usage)
acp/ agent.ts, rpc.ts, link.ts, hub.ts, share.ts, prompt.ts, turns.ts,
pending.ts, shellrc.ts — see below
update/ index.ts, source.ts, sshsig.ts, version.ts — see below
tui/ OpenTUI + Solid — see below
Outside src/: harness/ (the shared spec), schema/ (JSON Schemas for the two
YAML files, from bun run schema), scripts/ (build, dist, smoke, drive, ci,
licences, harness), install.sh and get.sh, third_party/.
The engine loop (src/session/engine.ts)
- Stream a reply from the provider; forward text, reasoning and tool-call deltas to the bus.
- No tool calls → done. Otherwise decide every call first (asking is sequential): parse and validate the arguments, build the tool's permission request, evaluate it, ask when needed. The same call three times running is always asked about.
- Run what was allowed: consecutive ordinary calls together, up to four; an
exclusivetool (such asask_user) alone; two calls on the same file in order. Errors become tool results, never exceptions. - Append the results and go round again.
Around that: messages sent meanwhile wait in the engine's inbox and enter
after the step's tool results as one user message marked STEER_MARK
(busy_input: queue holds them for the next prompt instead). Stop keeps what
streamed, so /continue picks up there. Budgets (harness/loop.json: wall
time, tool output bytes, written tokens) and the step ceiling withdraw the
tools and ask for an answer from what the model has. Context: before each
step, past compaction.auto_at of the window, old tool outputs are pruned, then
— if still over — the history is summarised and the turn's prompt put back
verbatim. Capacity (session/capacity.ts) refuses a subagent on a model its
server cannot hold beside the session's own.
Permission evaluation (src/permission/evaluate.ts)
Every path is judged where it really is (realPath).
- Hardline: the raw line, then each simple command in its plain spelling
(
plainCommands:VAR=prefixes, wrappers such assudo -u x,env -i,timeout 5,xargstaken off; quotes and program paths removed;sh -c "…"read as the line it runs); protected paths for writes. Deny. - Rules: defaults → global → project (trusted only) → session approvals.
Per pattern the last match wins, across patterns the strictest; an approval
never beats a written deny, and a deny on the plain spelling is a deny. A bash
line is split into simple commands; command substitution,
eval, nested shells or a redirect to a file turn anallowintoask. Files a command names follow thereadrules and the project boundary. - A path outside the project goes through
external_directory. - Mode:
autoallows;planallows reads and writes only under the project'splans/;editallows file reads and writes inside the project (not.git/, not the project's own config, agents, commands and skills);manualis the rules as they are. A subagent can be stricter than its parent, never looser.
"Always allow" covers the command's arity prefix (git commit -m x →
git commit *); a command that runs others is approved exactly, and a line where
one runs others offers no "always".
Providers (src/provider/)
Every dialect turns the provider-neutral Message[] into its request and its
stream into the same StreamEvents (text, reasoning, tool_call_delta,
usage, progress, notice, finish). What a provider needs back next turn
travels on the parts: Anthropic thinking signatures, a Responses reasoning item,
Gemini's thought signature. A part another provider cannot take is dropped on
conversion, never sent malformed. common.retrying() owns the retry rule: only
while nothing of the reply has been passed on, plus one attempt for a server
error. effort.ts and learned.ts remember an effort a server refused.
The session store and turns
session/store.ts is ~/.local/share/lembas/sessions.db (bun:sqlite), sessions
and messages with an FTS5 index for session_search and /sessions. Each prompt
is a turn (session/turns.ts): a snapshot before and after in a shadow git
repository (git/snapshot.ts, under ~/.local/share/lembas/snapshot/, borrowing
the project's objects, skipping .git, node_modules, the project's local/
and files over 2 MB, off above 20 000 files). /undo restores exactly the paths
the turn changed and drops it from the conversation; /redo reverses that;
/checkpoint keeps a tree under a name with its objects packed so a git gc in
the project cannot orphan it. Titles are set from the first prompt at once and
improved by the model after the first reply.
The system prompt (src/prompt/assemble.ts)
Identity → base and family overlay → environment, model roster, git state, task
board → AGENTS.md / CLAUDE.md and instruction_files → MCP server
instructions (scanned) → memory guidance and the memory snapshot → the skills
block → the mode → the person's custom instructions → the personality. The last
two use the same headings and order as the LLeMbas web UI. Memory and skills are
frozen per session (freeze() at start, /new and resume), so a save during
the session does not change the prompt under it and the provider's prompt cache
stays valid.
The TUI (src/tui/)
A subscriber like any other. state.ts turns bus events into a list of items,
buffering streamed text and flushing about 30 times a second; the permission
asker is a store entry whose resolve a card calls. index.tsx builds the
renderer and gives the terminal back before any fatal error; app.tsx is Root
(layout, keys, dialogs); components/ holds the prompt, messages, the
permission, question and plan cards, the spinner, the status bar, the todo list,
the first-run and setup screens; commands.ts the built-in slash commands;
palette.ts and theme.ts every colour; clipboard.ts copies by OSC 52 and the
system clipboard.
ACP and the link (src/acp/)
agent.ts— the CLI as an ACP agent:lembas serve --stdiofor an editor, and the far end of the LLeMbas link. A session is an ordinary session (createApp): same tools, permissions, prompts and hardline as the TUI. A client that says it is LLeMbas also gets every engine event as_lembas/event. Limits are the device's: the globalremote:block'sroots,max_mode, trust and approval timeout.rpc.ts— JSON-RPC 2.0, one message a line on stdio and one per WebSocket frame on the link; written here so its few rules are what LLeMbas implements in Python too.link.ts— the machine dials out to the instance it is logged in to; one connection per device, re-dialled with backoff. Nothing listens.hub.ts— one session, seen from the terminal and the web alike. The process holding the link (the service, or a terminal sharing with/remote) is the hub, on a Unix socket in the state directory (mode 0600). A web-started session runs in the hub; opening it in a terminal asks the hub to let go.share.ts— a terminal's side of the hub: its session shown in the web UI, the web as a second keyboard; an approval asked on both sides, first answer wins.prompt.ts— a web prompt made into what the TUI would have sent:/namethroughsession/commands.ts,@paththrough the TUI's expander, images and files stored as attachments (10 MB a block, 25 MB a prompt).turns.ts— turn ids, the last 200 per session in a file shared by the service and a terminal, so a prompt is never run twice across a handover or a restart;_lembas/session/statusreports running, queued and last finished.pending.ts— deletes the instance has not acknowledged, resent.shellrc.ts— shell integration for a terminal opened from the web: OSC 133 and OSC 7 marks for bash, zsh and fish.
The whole protocol, every _lembas/* method and capability, is
harness/acp.md. lembas/client.ts finds an instance
(/.well-known/lembas.json) and signs in by device code (RFC 8628);
lembas/webui.ts reads the account's models from /v1/models at every start.
Updates and signing (src/update/)
source.ts lists releases from GitHub's or Gitea's API or a static directory
(default: the public GitHub repository). version.ts compares SemVer; a channel
is stable or beta. index.ts installs: fetch SHA256SUMS and
SHA256SUMS.sig; sshsig.ts checks the SSH signature itself (PROTOCOL.sshsig,
namespace lembas-release, a key from RELEASE_KEYS or update.public_key,
ed25519 over the hashed list), with no ssh-keygen needed; the list's first line
must name the release's version; the asset must match its checksum; the
unpacked binary must report that version. The running binary is hard-linked to
.prev and the new one renamed over it; the running process keeps its inode.
A build from source is never updated, and nothing older than what runs is
installed unless asked for by name. RELEASE_KEYS is a list so the key can be
rotated: a release signed by the old key ships the new one.
The service (src/service.ts)
lembas service install writes ~/.config/systemd/user/lembas.service and
starts it; status, logs, uninstall, and run (what the unit runs: the link
and the hub, in the foreground). It runs as the user, never root, listens on
nothing, and says — rather than does — that lingering is needed to keep it
running while nobody is logged in.
Library, memory, skills, MCP, voice
- Memory is two small files (
MEMORY.md,USER.md) under the config directory, written by thememorytool and scanned bymemory/threats.ts. Skills areSKILL.mddirectories; only names and first lines enter the prompt, andskill_manageis all-or-nothing across the files it touches. - The library is one SQLite file: notes, knowledge bases, documents and
chunks (LLeMbas's chunker, shared by conformance), bm25 plus optional vectors,
fused by reciprocal rank. Logged in with
library: lembas, the account's library on the instance is used over/mcpinstead. - MCP:
McpManagerconnects every server at once without blocking the TUI; a server that connects mid-session is usable from the next prompt. Stdio servers get a minimal environment; HTTP servers try streamable HTTP, then SSE; OAuth is the SDK's flow with tokens inmcp-auth.json(mode 600). - Voice: every recorder delivers raw 16 kHz mono s16le on stdout, so the level meter and silence detection work the same; the recorder is its own process group and is killed with it.