The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
29 KiB
The _lembas extension of ACP
How LLeMbas (the web UI) and LLeMbas CLI (on a device) talk over the link: the Agent Client
Protocol, plus the _lembas/* methods and _meta.lembas fields below. LLeMbas CLI is the
agent; LLeMbas is the client. The device dials out (wss://<instance>/api/devices/link,
the device token in the Authorization header) and one WebSocket message is one JSON-RPC 2.0
message; lembas serve --stdio speaks the same protocol on stdin/stdout, one message a line, to an
editor (which gets plain ACP: none of the _lembas notifications, no question or plan cards).
This file is part of the harness spec: LLeMbas vendors it with the rest, and a change to the protocol is a change here first, then in both projects.
Versions and discovery
protocol(_meta.lembas.protocolininitialize,protocolin_lembas/hello): 2. CLIsrc/acp/agent.ts:LEMBAS_PROTOCOL, LLeMbasapi/devices.py:PROTOCOL.- Discovery (
/.well-known/lembas.json) saysprotocol: 1andprotocols: [1, 2]—protocolstays 1 on purpose, because a client that compares it for equality at login must still get in. The CLI readsprotocols(falling back toprotocolwhere it is absent) at login and again every time the link dials, keeps it with the login, and says protocol 2 inhelloandinitializeonly to an instance that lists 2; to any other it says 1 (an instance that speaks only 1 closes a link whose hello says 2 with 4400). A link closed with 4400 stops — it is not redialled — saying the instance and this CLI speak different link protocols — update one of them. - A protocol 1 peer keeps working. Every feature beyond protocol 1 is named by a capability
string in
initialize's_meta.lembas.capabilities, and LLeMbas never calls a method, nor relies on a field, the device did not list. The CLI logs in to an instance whose discovery says protocol 1 or 2. - A client says it is LLeMbas with
clientCapabilities._meta.lembasininitialize, and which protocol it speaks with_meta.lembas.protocol(absent: 1 — a protocol 1 client sendslembas: true). Only such a client gets_lembas/event; only one that says protocol 2 or later gets_lembas/questionand_lembas/plan, and one that then answers them with method-not-found is treated as protocol 1 for the rest of the link.
| Capability | What it promises |
|---|---|
directories |
_lembas/directories |
configure |
_lembas/configure |
steer |
_lembas/steer; unsent in a prompt's answer |
terminal |
_lembas/terminal/* (only with remote.terminal: true) |
sessions |
a session id lasts (opened from the store again); terminal sessions announced (announce, left, history) |
status |
_lembas/session/status |
delete |
_lembas/session/delete; _lembas/session/deleted |
compact |
_lembas/session/compact |
title |
_lembas/session/title |
files |
_lembas/files/search; @path in a prompt expanded |
commands |
available_commands_update, _lembas/commands; /name in a prompt expanded |
attachments |
image and resource blocks in session/prompt; attachments on prompt events and history |
ask |
_lembas/question |
plan |
_lembas/plan |
model |
the model event; _meta.lembas.connection in initialize |
turns |
_meta.lembas.turnId on session/prompt, deduplicated; turnId on prompt/task events; turn fields in status |
shell |
shell and integration in _lembas/terminal/open's answer (listed only with terminal) |
Standard ACP, as used here
initialize — client → agent
Params: { protocolVersion: 1, clientCapabilities: { _meta?: { lembas?: { protocol: 2 } | true } } }.
Result:
{
"protocolVersion": 1,
"agentCapabilities": {
"loadSession": false,
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
"mcpCapabilities": { "http": false, "sse": false }
},
"authMethods": [],
"agentInfo": { "name": "LLeMbas CLI", "version": "1.0.0" },
"_meta": { "lembas": {
"protocol": 2,
"harness_spec": "1.0.0",
"limits": { "roots": ["~/work"], "max_mode": "edit" },
"connection": "example",
"capabilities": ["directories", "configure", "…"]
} }
}
limits is null on serve --stdio. connection is the device's name for the instance — the
login's connection — or null; an instance that does not speak protocol 2 recognises its own
models by it, as <connection>/<name>, while the model event and announce say it directly
(instance).
How the device names an instance's models. An instance that speaks protocol 2 serves every model
as <provider>/<model> (deepseek/deepseek-flash), and that served id is the device's ref for
it — each provider is, as the person sees it, a connection of its own, backed by the instance
(requests still go to its /v1, with the served id). Exceptions: a connection of the device's own
with the provider's name keeps the short form, and the instance's model is then
<login>/<provider>/<model>; with two instances serving one provider, the one logged in to first
keeps the short form and the other's are <login>/<provider>/<model>; an instance that does not
speak protocol 2 serves bare ids, which stay <login>/<id>. The provider is the model's provider field where the
instance gives one, else — only from an instance whose discovery lists protocol 2 — the part of the
id before its first /; an id with a / from any other instance is just an id (<login>/<id>). The old forms (<login>/<model>,
<login>/<provider>/<model>) still name the same model. lembas_model in session/new and
model in _lembas/configure are the instance's served name, as it serves it.
session/new — client → agent
Params: { cwd: <absolute>, mcpServers?: [], _meta?: { mode?, lembas_model?, model?, effort? } }.
cwd must be a directory inside remote.roots, in a trusted project when remote.require_trust
(the default) — otherwise -32602 with the reason. mode is lowered to remote.max_mode.
lembas_model is the instance's name of a model (it becomes <connection>/<name> here); model
a ref of the device's own; effort a level or "off".
Result: { sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort } } }.
The id is the device's store id and lasts: a session the client names later that is not in memory
is opened from the store under the same rules (capability sessions). With capability commands,
an available_commands_update follows the answer.
session/prompt — client → agent
Params: { sessionId, prompt: ContentBlock[], _meta?: { lembas?: { turnId?: string } } }.
Blocks (capability attachments for the last two kinds):
| Block | Becomes |
|---|---|
{ type: "text", text } |
the prompt's text (blocks joined by a blank line) |
{ type: "resource_link", uri } |
(see <uri>) in the text |
{ type: "image", data: <base64>, mimeType, uri? } |
written under the session's attachment directory and attached by path, as the TUI does with a pasted image: an image part for a vision model, a line saying it is there for any other. mimeType: image/png, image/jpeg, image/webp, image/gif |
{ type: "resource", resource: { uri, mimeType?, text } } |
written there and attached like an @file (line-numbered, counted as read) |
{ type: "resource", resource: { uri, mimeType?, blob: <base64> } } |
written there and referenced by path ((binary file, N bytes)), or attached as an image when it is one |
Limits: 10 MB a block and 25 MB a prompt, decoded; over either, -32602 with a sentence. The
attachment directory is ~/.local/state/lembas/attachments/<sessionId>/, removed with the session;
each upload gets a name of its own there (<time>-<random>-<name>), so two of one name stay two.
A command's mode in a session shared under the limits is lowered to remote.max_mode, as it is in
a session the service runs; a command's model holds for its prompt, and the model and effort the
session had come back afterwards.
The text is then expanded as the TUI would (capabilities files, commands):
- A text starting with
/name, wherenameis in the session's command set (below), is that command: a prompt command sends its expanded text (shown in the transcript as typed);compact,undoandplanwithout a task are actions and send nothing to the model. A/namenot in the set — a path, a regex, a TUI-only command — is sent to the model as plain text,/and all. - Writing a path: a space or a
#in a name is escaped with a backslash —@my\ notes.md,@a\#b.md— which is how the TUI's picker writes it and how the expander reads it; an unescaped#followed by digits is a line range (@c.md#10-20). @pathmentions in what the person typed are attached by the TUI's expander, with its limits (project/attach.ts): files line-numbered,@path#10-20a range,@dir/a listing, images by vision. Never from a command's own text (a skill's, a custom command's body, a diff). Only a path that is — symlinks followed — inside the session's project and insideremote.roots, and is not a protected path, is attached; any other@wordstays plain text and is not read. In a terminal's shared session the same holds for a prompt from the web (the device's roots; only the project when the session was shared by name and no roots are set).
Turn ids (capability turns): turnId is the client's id for this turn (LLeMbas: its user
message id). The device keeps, per session, the last 200 ids it accepted with their state
queued | running | done — the finished ones with their answers also in one file per session
(~/.local/state/lembas/turns/<sessionId>.json, written atomically and merged), which the service
and every terminal use alike, so they outlive a restart and a session moving between a terminal and
the service in either direction; files untouched for 30 days are pruned — and a prompt with a known turnId does not run again: it gets the
original's answer — when that one is done, or at once if it is. Without a turnId the device makes
one. A prompt that arrives while the session works waits its turn.
Result: { stopReason: "end_turn" | "max_turn_requests" | "max_tokens" | "cancelled", _meta: { lembas: { turnId, … } } },
where _meta.lembas may also carry:
| Field | When |
|---|---|
unsent: string[] |
messages sent with _lembas/steer that the turn ended before taking in — send them next |
command: string |
the prompt was that command |
summary: string |
/compact |
undone: { prompt, files } | null |
/undo (null: nothing to undo) |
mode: string |
/plan without a task: the mode now |
message: string |
a command that had nothing to do (/review with no changes), said instead |
session/set_mode — client → agent
Params: { sessionId, modeId }, lowered to remote.max_mode. Result {}.
session/cancel — client → agent (notification)
Params: { sessionId }. Stops the running turn.
session/update — agent → client (notification)
{ sessionId, update } with sessionUpdate one of agent_message_chunk, agent_thought_chunk,
tool_call, tool_call_update, plan (the todo list), current_mode_update, and
available_commands_update (capability commands):
{ "sessionUpdate": "available_commands_update",
"availableCommands": [ { "name": "review", "description": "…", "input": { "hint": "head | <branch>" },
"_meta": { "lembas": { "kind": "builtin" } } } ] }
Sent after session/new, after a session is opened from the store or taken over by a new link,
when a terminal's session is shared, and after a turn whenever the set changed (a skill made, a
command file written). kind: builtin, custom (.agent/commands, ~/.config/lembas/commands)
or skill. The set is the shared one only: the built-ins compact, plan, undo, review,
changelog, init, continue, then custom commands, then skills. Commands that need the TUI's
own screens (sessions, settings, theme, login, quit, …) are never listed; a custom command or skill
named like any TUI command is not a command (the built-in wins in the TUI).
session/request_permission — agent → client
An approval card. Params: ACP's { sessionId, toolCall: { toolCallId, title, kind, status: "pending", rawInput, content? }, options }
with options once (allow_once), session (allow_always, absent where "always" is never offered)
and deny (reject_once), and _meta.lembas: { reason, purpose?, always?, patient? }. patient: a
person may also answer at the terminal (a shared session), so the client does not time it out.
Answer: ACP's { outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }, with
optional _meta.lembas.command (the corrected command line, once only) or
_meta.lembas.feedback (why it was denied). No answer within remote.approval_timeout is a denial.
_lembas/* methods
Direction is who sends it. "→ result" marks a request; the others are notifications.
_lembas/hello — agent → client
Sent once when the link opens, before initialize:
{ protocol, version, harness_spec, device: { host, platform }, limits: { roots, max_mode, require_trust } }.
_lembas/event — agent → client
{ sessionId, event }: every engine event of the session, to a client that said _meta.lembas.
event.type is one of session, prompt, step, title, text, reasoning,
tool_call_delta, progress, tool_start, tool_end, tool_denied, usage, inbox,
steered, task, notice, retract, todos, compacted, sub_tool, mode, model, flash,
setting, error, done (src/bus/index.ts). Those this protocol relies on:
| Event | Fields |
|---|---|
prompt |
text (as the person sees it: a command's name, not its body), turnId, attachments?: [{ name, mimeType, size }] |
task |
state: "start" | "end", turnId — every turn has one: the client's, or one made here for a turn typed in a terminal |
steered |
texts, ids? — the _lembas/steer message ids taken in at this step |
mode |
mode |
model |
ref (the device's ref, above), effort (a level or "off"), connection (the device connection it is spoken to through) and instance: boolean — true exactly when that connection is the login of this link's instance, i.e. the model is one the instance serves (its served id is ref, or ref less a leading <connection>/). Whenever either changes, for any reason: /model, /effort, the settings tool, a fallback, _lembas/configure |
compacted |
summary |
title |
id, title |
_lembas/directories — client → agent → result
Params { path?: string }. Without a path: { path: "", places: Dir[] }, the remote roots and every
trusted directory inside them. With one (absolute, inside the roots): { …Dir, parent, entries: Dir[], error? }.
Dir = { path, name, accepted: boolean, reason? } — whether session/new would accept it.
Only on the link (an error on serve --stdio).
_lembas/configure — client → agent → result
Params { sessionId, model?: <instance's name>, effort?: <level> | "off" } between prompts. Result
{ model: <ref>, effort }. Emits a model event when something changed.
_lembas/steer — client → agent → result
Params { sessionId, text, messageId? }: a message for the running task, given to the model at its
next step. Result { queued: number }. -32602 when nothing runs: send it as a prompt instead.
_lembas/session/history — client → agent → result
Params { sessionId }. Result { sessionId, title, root, turns }, each turn
{ role: "user", text, turnId?, attachments?: [{ name, mimeType, size }] } (turnId
the turn the message started — the client's, or the one made for a turn typed in a terminal — kept
with the message in the store; attachments, and the text without them) or { role: "assistant", text, reasoning, tools: [{ id, name, args, output?, isError? }] }.
_lembas/session/status — client → agent → result
Params { sessionId }. Result
{ sessionId, exists, held: "here" | "terminal" | "none", busy, title, turnId?, queued: string[], lastTurnId? }
— the turn fields with capability turns: the turn running now, the ones waiting, the last
one finished (for a session not open, held: "none", the last one it finished before). Reattaching after a restart or a dropped link, for an incomplete row with turn T:
T running → follow it; T queued → wait, and follow it when it starts; T done → import it from
history; T unknown → send the prompt again (safe: the device deduplicates).
_lembas/session/delete — client → agent → result
Params { sessionId }: the chat was deleted. The session stops if it works, leaves the store (its
attachments too), and a terminal that has it open starts a new one. Result { deleted: boolean };
false when there is no such session (any more), which is done as well.
Which sessions the device deletes: one the client holds
or a terminal shares now, one the client was ever given (made by session/new, opened from the
store, or shared from a terminal — the device keeps a mark), and otherwise one whose root is inside
remote.roots on a device whose remote work is on. Trust is not asked: deleting runs nothing in
the directory. Anything else is -32602 with the reason. (Not the rule for opening a session,
trust included: that one would refuse sessions the client had been showing.)
The client keeps a delete it could not deliver — the device was not linked, did not answer, or
refused — and sends it again when the device next links, until it is answered (LLeMbas keeps it in its
database and gives up after five refusals). A delete is idempotent, so sending it twice
is safe. A session pending deletion is not made into a chat again by an announce.
_lembas/session/compact — client → agent → result
Params { sessionId }. Result { summary }. -32602 while it works.
_lembas/session/title — client → agent → result
Params { sessionId, title } (at most 200 characters). Result { title }.
_lembas/session/announce — agent → client
A terminal on the device shares its session:
{ sessionId, cwd, title, model, instance, mode, effort, busy, origin: "terminal" }. Sent when it is
shared and again whenever the link comes up. instance: the session's model is this link's
instance's, as on the model event.
_lembas/session/left — agent → client
{ sessionId, available: boolean }: the terminal let go of it. available: the service can open
it from the store at the next prompt.
_lembas/session/deleted — agent → client
{ sessionId }: deleted on the device (/delete, /sessions delete); delete the chat.
A delete is never dropped for want of a link. One made while no link is up — no
service running, the instance unreachable, or lembas sessions delete / prune from a shell — is
kept on the device, and every one kept is sent right after the next link's initialize (to a
client that said _meta.lembas), then forgotten. So the client must expect a deleted some time
after the fact, for a session it may never have had a chat for, or has deleted already: either is
ignored. Also sent for the empty session a terminal's resume discards (-c, /sessions) where the
web UI had been shown it.
_lembas/permission/settled — agent → client
{ sessionId, toolCallId }: take the card with this toolCallId down — answered elsewhere (at the
terminal), or no longer waited for (the device's time ran out). It covers approvals,
questions and plans alike. Cards are keyed by toolCallId, never by session: several can be open
at once, and settling one never hides another.
_lembas/question — agent → client → result (capability ask)
The ask_user tool, in a session the device holds or a terminal shares, asked of a client that said
_meta.lembas. Params:
{ "sessionId": "ses_…", "toolCallId": "call_…", "why": "optional",
"questions": [ { "header": "DB", "question": "Which database?", "multiple": false,
"options": [ { "label": "SQLite", "description": "…", "recommended": true } ] } ],
"_meta": { "lembas": { "patient": true } } }
At most 4 questions of at most 6 options; the recommended option, if any, is first. An option's
label is at most 240 characters on the card (LLeMbas shows no more); answered with the cut label,
the model is given the whole one. Result
{ outcome: "answered", answers: Answer[] }, one per question in order, or { outcome: "dismissed" }.
Answer is { kind: "options", labels: string[] }, { kind: "custom", text } or
{ kind: "back", text } (a question of the person's own, which the model answers before asking
again) or { kind: "skipped" } (that question left blank; the model is told it has no answer).
Anything else reads as dismissed.
_lembas/plan — agent → client → result (capability plan)
plan_submit, likewise. Params { sessionId, toolCallId, path, text, _meta: { lembas: { patient? } } }
— path as the model sees it (relative to the project), text the plan's markdown. Result
{ outcome: "approve", mode: "edit" | "manual" } (the session continues in that mode, lowered to
remote.max_mode; where even manual is beyond it, nothing is decided),
{ outcome: "revise", feedback }, or { outcome: "dismissed" }.
For both: first answer wins. In a terminal's shared session the card is shown at the terminal
and in the web UI at once (patient: true); whichever answers first decides, and the other card is
taken down (_lembas/permission/settled when the terminal answered). Not patient: LLeMbas times
the card out like an approval and answers dismissed; the device also stops waiting after
remote.approval_timeout, answers the model as dismissed, and sends settled. A client that did
not say _meta.lembas gets neither request: ask_user then tells the model nobody can be asked,
and plan_submit saves the plan and tells it to stop.
_lembas/files/search — client → agent → result (capability files)
The TUI's @ picker, for the web composer. Params
{ sessionId?: string, cwd?: string, query: string, limit?: number } — one of sessionId (the
session's directory) or cwd (absolute; the same rules as session/new); limit 20 by default, at
most 50. Answered from the device's own file index (project/files.ts: ripgrep's list, so
gitignored files are left out; fuzzy, nudged by frecency; an empty query gives the recent ones).
Result { root, files: [{ path, kind: "file" | "dir" }] }, path relative to root and
/-separated. root is the directory @path mentions in that session's prompts are read from —
its working directory (for a terminal's session, the directory the terminal works in, which may be
below the project root the announce names), the project root for a session opened there. A chosen file goes into the
composer as @path text, which the device expands when the prompt comes.
_lembas/commands — client → agent → result (capability commands)
Params { sessionId?: string, cwd?: string }, for a composer that has a directory but no session
yet. Result { commands: [same shape as availableCommands] }.
_lembas/terminal/open — client → agent → result
Only with remote.terminal: true. Params { cwd?, cols?, rows? } (cwd defaults to the first
root; 20–500 × 5–300). A login shell as the device's user, in a PTY, at most four per link. Result
{ terminalId, cwd, cols, rows, shell: "bash" | "zsh" | "fish" | "other", integration: boolean }
(shell and integration with capability shell).
Shell integration: for bash, zsh and fish, unless remote.terminal_integration: false
(global config), the shell starts with an rc wrapper that sources the user's own startup files first
— as a login shell reads them — and then marks every prompt with OSC 133 (A prompt start, B
command start, C output start, D;<exit status> when a command has finished; an empty line ends
no command) and OSC 7 (file://<host><cwd>, the path percent-encoded byte by byte). B is put back after any hook that
rebuilds the prompt (starship, powerline). bash runs as an interactive shell that reads the login
files itself (/etc/profile with PS1 unset, so /etc/bash.bashrc is read once), and keeps
logout and ~/.bash_logout; zsh follows a ZDOTDIR set in the user's own .zshenv, and our
variables are gone before their .zshrc runs. Nothing else in the user's environment changes. The
web terminal reads the marks; nothing on the server parses them.
_lembas/terminal/input — client → agent
{ terminalId, data: <base64> }: keystrokes.
_lembas/terminal/resize — client → agent
{ terminalId, cols, rows }.
_lembas/terminal/close — client → agent
{ terminalId }: hung up (SIGHUP). Every terminal closes with the link.
_lembas/terminal/output — agent → client
{ terminalId, data: <base64> }.
_lembas/terminal/exit — agent → client
{ terminalId, code }: the shell ended.
The HTTP API the CLI uses (LLeMbas /v1, the device token's own user)
Besides /v1/models, the chat completions, /api/v1/instance and /mcp:
-
GET /v1/models: each entry may carrykind—"chat","embedding","stt","tts"or"image". Onlychat(or nokind, from an instance that lists chat models only) is a model the CLI talks to: its model refs,/modeland the login's list are those. Anembeddingmodel is the library's: logging in (or in again) sets config.yaml'sembedding:to the first one as<connection>/<served id>when noembedding:is set, and never replaces one that is;embedding: <served id>also finds it. Voice uses the instance's own speech endpoints, not these entries. Any other kind is ignored. -
GET /v1/usage→{ plan: { name, credits_per_month } | null, balance: number | null, month: { from, tokens_in, tokens_out, cost: number | null }, device: { tokens_in, tokens_out, cost: number | null }, unit: "credits" }. The CLI shows the balance in its status bar whenplanis not null, and the whole of it under/usage. A 404 (an instance without it) shows nothing.Also read, every one optional (an instance without them gets credits alone):
currency("EUR"|"USD"|"GBP"),credit_value(money per credit),admin(boolean: no limit, never refused),balance_money,month.cost_moneyanddevice.cost_money(numbers ornull), andwindows: [{ window, limit, spent, frees_at }](rolling spending windows:windownames it,limitandspentin credits,frees_atan ISO time ornull). Credits are shown with up to two decimals below 1,000 and whole above; beside each, its money incurrencywhere given (€1.23,$1.23,£1.23) —*_moneywhen sent, else the credits timescredit_value.admin: trueshowsunlimitedin the status bar whatever the plan, and/usagesays so, still listing the month's and the device's spend; its windows are not listed. Otherwise/usagelists each window's spend, limit and when it frees. -
GET /v1/me/personalization→{ enabled, available, personality, personality_custom, instructions };PUTthe same fields (403 when personalization is notavailableor the token is not a device's token — the response'sdetailsays which, and the CLI shows it, else "not allowed"; 422 for anenabledthat is not a boolean or a field that is not a string). Logged in to exactly one instance that has itavailable, the CLI uses these in place of its localpersonality,personality_customandinstructions(none of them whenenabledis false; a preset the CLI does not know is none here), and/settings(orlembas config set) writes a change back with aPUTof only the field changed andenabled: true— never the session's copy of the other two, which may be blanked or mapped — so the instance keeps what it has saved. LLeMbas must accept a partialPUT. Logged out, the local keys apply. -
The library: logged in to one instance whose token has the
libraryscope, a config that sets nolibrary:uses the account's (lembas);library: localwritten stays local.
Notes
_lembas/files/search'srootis the session's working directory, which@pathmentions resolve against (for a session opened at the project root, the project root)./planwithout a task is an action, with a task a prompt run in plan mode. Questions and plans also stop waiting after the device'sremote.approval_timeout, like approvals.- The CLI's login accepts an instance whose discovery says protocol 1 or 2.