# 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`): **2**. CLI `src/acp/agent.ts:LEMBAS_PROTOCOL`, LLeMbas `api/devices.py:PROTOCOL`. - **Discovery** (`/.well-known/lembas.json`) says `protocol: 1` **and** `protocols: [1, 2]` — `protocol` stays 1 on purpose, because a client that compares it for equality at login must still get in. The CLI reads `protocols` (falling back to `protocol` where it is absent) at login and again **every time the link dials**, keeps it with the login, and says protocol 2 in `hello` and `initialize` only 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.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`, 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: ```json { "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 `/`, 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 `/` (`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 `//`; with two instances serving one provider, the one logged in to first keeps the short form and the other's are `//`; an instance that does not speak protocol 2 serves bare ids, which stay `/`. 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 (`/`). The old forms (`/`, `//`) 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: , 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 `/` 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 )` 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 (`