# 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:///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.protocol` in `initialize`, `protocol` in `_lembas/hello`): **3**. CLI `src/acp/agent.ts:LEMBAS_PROTOCOL`, LLeMbas `services/device_link.py:PROTOCOL`. - **3 is LLeMbas 2.0 and LLeMbas CLI 2.0, and they speak it alone** (spec 3.0.0). A 2.0 server keeps the server's file system, memory and models, and a 2.0 CLI talks to nothing else, so a 1.x peer and a 2.0 one **refuse each other**, each saying what to update: - **Discovery** (`/.well-known/lembas.json`) of a 2.0 server says `protocol: 3` **and** `protocols: [3]`. `protocol` was frozen at 1 for 1.x so that a client comparing it for equality could still log in; 3 raises it on purpose, so a 1.x CLI's login is refused there (it says the server speaks protocol 3 and to update the older of the two). - A **2.0 CLI** logs in only where discovery lists 3 (`protocols`, falling back to `protocol`). To a server that does not, at login and again at every dial of the link, it says *<server> runs LLeMbas X, which speaks link protocol 1 and 2; this LLeMbas CLI needs a LLeMbas 2.0 server (protocol 3) — update the server, or use LLeMbas CLI 1.x with it*, and the link stops. - A **2.0 server** closes a link whose hello says 1 or 2 with **4400** and the reason *update LLeMbas CLI to 2.0*; a 1.x CLI stops on 4400 without redialling. A 2.0 CLI's hello says 3, and a 1.x server closes it with 4400 likewise; the CLI stops and says the server needs updating. - **Within protocol 3** every feature is still 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**. - A client says it is LLeMbas with `clientCapabilities._meta.lembas` in `initialize`, and which protocol it speaks with `_meta.lembas.protocol` (absent: 1 — a protocol 1 client sends `lembas: true`). Only such a client gets `_lembas/event`; only one that says protocol **2 or later** gets `_lembas/question` and `_lembas/plan` (a 2.0 server says 3), and one that then answers them with method-not-found is treated as protocol 1 for the rest of the link — what an editor on `serve --stdio` is. | 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`) | | `context` | `system_prompt` and `personalization` in `session/new` and `session/prompt` `_meta.lembas` are read | | `library` | `library` in `session/new` and `session/prompt` `_meta.lembas` is read; `_meta.lembas.scope` sent on the instance library's `tools/call`. Spec 3.0.0: an instance's chats have no knowledge bases, so a 3.0.0 instance sends no `library`; the scope is still sent on its `memory` calls (*The HTTP API*) | | `prototypes` | `prototypes` in `session/new` and `session/prompt` `_meta.lembas` is read. Spec 3.0.0: a 3.0.0 instance sends none (`show` replaces prototypes) | | `plan_state` | the structured plan: `_meta.lembas.plan` on the `plan` session update and on `_lembas/plan`; `plan` in `_lembas/session/status` | | `rewind` | `_lembas/session/rewind` | | `show` | spec 3.0.0: `_lembas/show`; the `show` tool is offered in a session the client opened when the client said `show: true` in `clientCapabilities._meta.lembas` | | `jobs` | background work (spec 2.0.0): the `job` and `job_note` events; `_lembas/jobs`, `_lembas/job/output`, `_lembas/job/stop`, `_lembas/job/send`, `_lembas/job/transcript`; `jobs` in `_lembas/session/status`; `job` on `session/request_permission`; `machine` on a `prompt` event | ## Standard ACP, as used here ### `initialize` — client → agent Params: `{ protocolVersion: 1, clientCapabilities: { _meta?: { lembas?: { protocol: 3, show?: true } | true } } }`. Result: ```json { "protocolVersion": 1, "agentCapabilities": { "loadSession": false, "promptCapabilities": { "image": true, "audio": false, "embeddedContext": true }, "mcpCapabilities": { "http": false, "sse": false } }, "authMethods": [], "agentInfo": { "name": "LLeMbas CLI", "version": "2.0.0" }, "_meta": { "lembas": { "protocol": 3, "harness_spec": "3.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`; the `model` event and `announce` say directly whether a model is the instance's (`instance`). **How the device names an instance's models.** The served id **is the device's ref** for a model (`deepseek/deepseek-flash`; spec 3.0.0: a 2.0 device talks to one server, the one it is logged in to, so there is nothing to tell apart — no connection prefix, no second login, no connection of the device's own). The older forms (`/`, and from before providers `/` for the one served `/` of that name) 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. `model` in a `model` event and in a result is the device's ref, which is the served id. ### `session/new` — client → agent Params: `{ cwd: , mcpServers?: [], _meta?: { mode?, lembas_model?, model?, effort?, lembas?: Context } }`. `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; `model` a ref the device knows (an editor's choice); `effort` a level or `"off"`. Result: `{ sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort, instance } } }`. `instance` (spec 3.0.0) is a boolean, with the `model` event's meaning: true exactly when the session's model is one this link's instance serves. **The chat's model, full stop** (spec 3.0.0). A session the client opens with `lembas_model` runs on **that** model and on no other: the device takes it as given, for every turn of the session, until the client changes it (`_lembas/configure`). If the device cannot speak to it — the instance does not serve it to this device, or this device is not logged in to the link's instance — `session/new` fails with `-32602` and the reason (so does a `_lembas/configure` naming such a model, and the session keeps the model it had), and a turn that loses the model midway fails with its error; the device never answers on a model of its own instead, and never falls back on its own. So `instance` is true for every session the client opened, and a client has nothing to say about a device having "answered on its own model": that notice is gone. A session a terminal shares (`announce`) keeps the terminal's model, and its `instance` says which it is. 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. **`_meta.lembas` — what only the client knows** (`Context`, read on `session/new` and again on every `session/prompt`, the newer replacing the older; the device composes its own prompt from the spec's texts, as for its own sessions, and these are the pieces the instance holds): | Field | Capability | What | |---|---|---| | `system_prompt: string` | `context` | the chat's effective authored prompt (chat over folder over model over instance), appended after the shared text as `## Instructions from `; empty: none | | `personalization: { personality, personality_custom, instructions } \| null` | `context` | the account's, as `/v1/me/personalization` gives them, used in place of the device's own for this session; `null`: the device's own | | `library: { bases: [{ id, name }] }` | `library` | the knowledge bases this chat may search, named to the model (`blocks/library.md`'s `{{knowledge_scope}}`) and sent back as the scope on every library call (below); an empty list: the account's whole library, as before | | `prototypes: { offered: boolean, sources: string }` | `prototypes` | whether the model may write a prototype (`blocks/prototypes.md`), and the sentence saying what a page may load (a device offering them on its own, with no instance, sends nothing and the bullet drops) | ### `session/prompt` — client → agent Params: `{ sessionId, prompt: ContentBlock[], _meta?: { lembas?: { turnId?: string, ...Context } } }`. 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 )` in the text | | `{ type: "image", data: , 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: } }` | 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//`, removed with the session; each upload gets a name of its own there (`