Prune unit tests down to what E2E cannot catch
Every unit test was sorted by the writing-tests policy: deleted when it restated the implementation, pinned constants or formatting, or duplicated E2E coverage; moved to test/e2e when the behaviour is observable end to end; kept, with a comment on the bug it catches, when it guards an invariant or bug-prone logic E2E cannot reach (provider wire formats, security boundaries, process supervision races, migrations, TUI interaction). Test code drops from 26.5k to 11.8k lines: gleam test 298 -> 106 tests, with python_test, compaction_test and daemon_test added to the E2E suite (43 tests). gleam test now runs against a temporary ALBEDO_HOME, so a developer's own ~/.albedo settings no longer fail it, and the E2E harness refuses to boot its shared daemon from a process that never exits. Also finishes moving docs/ to robot-docs/.
11 unmodified lines121314151617181916211718192021222324253 unmodified lines11 unmodified linesgleam format --check src testcargo test --release --locked --manifest-path native/render/Cargo.tomlnative/render/install.shgleam testpython3 test/harness/python_plugin_test.pypython3 test/harness/commands_plugin_test.pypython3 test/harness/files_plugin_test.pypython3 test/harness/view_plugin_test.pypython3 test/harness/api_docs_test.pypython3 test/harness/trace_test.pypython3 test/harness/job_wake_test.pypython3 test/harness/run_plugin_test.pypython3 test/harness/remote_kernel_test.pypython3 test/harness/remote_plugin_test.pypython3 test/harness/process_supervision_test.py
(cd cli && go vet ./...)(cd cli && go test ./...)# the daemon suites drive the CLI through this binary3 unmodified lines12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061# agents
a subagent is a session with a parent. a message is mail. both are daemon primitives; the `mail` and `agents` extensions (on by default) give the model its python surface.
## python
```pythonm = await agents.models() # required once before spawningkid = await agents.self.spawn("map every wake path", name="scout", model=m[0], deliverable="notes/wakes.md") # returns at onceawait mail.submit(kid, "also cover schedules") # or "parent", a name, a session idkids = await agents.self.children() # snapshots: running, closedawait kid.cancel() # stop its turn, keep everythingawait kid.close() # done: keep transcript + files, free the kernelawait agents.progress("on turn.gleam now") # shows in /agents, starts no turn```
- spawn never returns the child's answer: the answer arrives later as `<mail>` and starts the parent's next turn. a child whose turn ends without answering forwards its last message, marked `unreviewed`.- names resolve inside the family (children, siblings, parent); anything else takes a session id.- cancel and close work on your own children only. the model cannot delete an agent; it asks the user.- limits: 3 deep, 12 open children per parent, 1 MiB per letter, 1000 undelivered letters per session.
## mail delivery
a letter is stored first and marked delivered in the same transaction that writes it into the recipient's transcript. an idle recipient starts a turn; a running one reads it at its next step. undelivered letters retry every 15 seconds, so a restart or a session that is not running yet loses nothing. webhook deliveries are letters from outside, and wait for an idle session.
## the orchestrator view
`ctrl+o` or `/agents` in a session shows its whole tree live: running agents pulse with their token rate, mail travels the edges as blocks. tab picks an agent, enter opens it (typing there is you, as the user), text + enter sends to it, `/spawn <name> <task>` starts a child under it.
routes: `GET /agents?session=<id>` (the tree), `GET /agents/stream` (batched events), `POST /sessions/:id/children`, `POST /sessions/:id/mail`.
## swarm overhead
kernel boots queue behind four slots. each boot owns only its session's composition,and each kernel's host callback captures only its routes, store handle, and sessionid—not a snapshot of the other agents. run waits on process-exit notifications,not periodic exit checks; descendant cleanup and deadlines still apply.
session replay retains at most 256 events or 4 mib, evicting incrementally ratherthan copying the full window per token. missing events require a transcript reset.the orchestrator feed batches every 100 ms and skips activity serialization whennobody is watching.
run jobs start at once, at low priority (`nice`, and utility QoS on macOS), so abusy swarm yields to the person at the machine. a job still running after the gracewindow (5 s) is heavy and needs one of a few daemon-wide slots; without one it ispaused (`SIGSTOP`, `job.queued` is true) and resumed when one frees, so waitingcosts no cpu and loses no work. quick commands never wait. the timeout counts onlytime the command ran. `await job.stop()` resumes a paused job so it can exit.
slots default to the machine's cores and shrink while the one-minute load averageruns past 1.25× the cores, which catches heavy jobs that fan out workers of theirown. a freed slot goes to the kernel holding the fewest, oldest request first, soone busy agent cannot starve the rest. a slot is released only after process-groupcleanup is confirmed; failed cleanup keeps it held.
settings, read when the daemon starts: `ALBEDO_MAX_LOCAL_JOBS` fixes the slot count(1–256), `ALBEDO_JOB_GRACE_SECONDS` sets the grace window, `ALBEDO_JOB_LOAD=0`turns off the load adjustment. this bounds sustained shell work, not agent/modelconcurrency, remote kernels, or python computed directly in a cell.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950# model authentication
model authentication is supplied by model-provider extensions rather than the daemon configuration loader.
## openai-compatible api keys
The enabled-by-default `openai` extension requires `models`. It handles existing named OpenAI-compatible profiles and both Responses and Chat Completions. Model discovery comes from the models.dev cache, not the configured API endpoint. Older `config.json` profiles without an `extension` field remain OpenAI profiles; newly saved profiles include `"extension": "openai"`.
## chatgpt codex oauth
The enabled-by-default `codex` extension requires `openai`, so it reuses the OpenAI Responses encoder, SSE transport, replay format, and model metadata. Its picker shows the models the ChatGPT backend offers the signed-in account (see [codex models](models.md#codex-models)); API-key OpenAI profiles still show the full OpenAI catalog. `/login` can add ChatGPT Plus/Pro accounts. A fresh install can enter `codex` as the provider name; an existing install can choose **add chatgpt codex account**.
The daemon runs the login (see [sign-in api](#sign-in-api)) with the Codex CLI browser flow: PKCE S256, the allowlisted `http://localhost:1455/auth/callback`, state validation, and a manual callback-url/code input that races the local callback. The token exchange uses OpenAI's public Codex client id and stores access, rotating refresh, expiry, ChatGPT workspace id, seat id when present, and email when present.
Credentials live in `$ALBEDO_HOME/auth.json`, mode `0600`, under the `openai-codex` key. One account uses the legacy object shape; two or more use an array. A login replaces the same seat/account identity and appends a different account. `/login` lists saved providers and each stored account; `d` (or delete) on either row asks to remove it, and enter on an account does the same. Removing the active provider makes the first remaining provider by name active. A Codex provider with no accounts left reads `signed out` and starts a sign-in when chosen. Access tokens refresh under `auth.lock` 60 seconds before expiry, with a re-read under the lock so concurrent sessions do not overwrite a newer refresh.
Each session hashes its id into the account pool and stays on that account. If that account is invalid or cannot refresh, selection continues through its siblings. When the Codex backend answers a request with 401 (for example `token_revoked`), that account is removed from `auth.json` and the turn fails with an error ending in `run /login`; the TUI opens `/login` when that error follows a turn sent from the open chat. Requests use `https://chatgpt.com/backend-api/codex/responses` with Bearer auth, `chatgpt-account-id`, Codex experimental Responses headers, and the session request id. Codex request bodies force `store: false`, automatic parallel tools, low text verbosity, encrypted reasoning replay, and nullable tool strictness.
The oauth callback binds only to `127.0.0.1`. Port 1455 is fixed because OpenAI allowlists the exact redirect URI; a busy port fails instead of silently changing redirects.
## sign-in api
Browser sign-ins run in the daemon, so every client shares one OAuth implementation. An extension contributes `extension.LoginPlugin(oauth.Login(...))`: the profile extension it serves, a chooser label and detail, the protocol its profiles use, its auth.json key, its loopback callback (host, port, path, and whether the port is fixed), and three functions. `authorize` builds the provider url from a `Grant` (redirect, state, PKCE verifier and challenge). `exchange` trades the code for the credential object to store and may report progress. `account` names a stored credential; equal ids are the same account. `albedo_oauth` owns the rest: the callback listener, the race with a pasted code, a 5-minute timeout, and locked writes. A sign-in replaces the stored account with the same id and appends a new one. One account is stored as an object, two or more as an array.
Clients use these daemon routes. All of them require the daemon token.
- `GET /auth` returns `{logins: [{provider, label, detail, protocol}], accounts: [{provider, id, label, detail, selected}]}`. Accounts whose labels collide get the start of their id appended.- `POST /auth/{provider}` starts a sign-in and returns `201 {id, url}`. The client opens `url` in a browser unless `ALBEDO_NO_BROWSER` is set, and shows it for copying.- `GET /auth/logins/{id}` returns `{state, message}`. `state` is `waiting`, `exchanging`, `done`, or `failed`; `message` is progress, the new account's label when done, or the failure reason. Clients poll it. A settled sign-in is forgotten after 10 minutes.- `POST /auth/logins/{id}` with `{input}` delivers a pasted callback url, `code#state`, query string, or bare code.- `DELETE /auth/logins/{id}` cancels the sign-in and closes its callback listener.- `POST /auth/{provider}/accounts/{id}` selects that account; `DELETE` on the same path removes it.
After a sign-in finishes, the client lists models with `GET /models/{provider}` and saves the profile `{extension: provider, model, protocol}` itself. Profiles stay client-owned; credentials are daemon-owned.
## claude code oauth
The enabled-by-default `claude` extension adds Claude Pro/Max accounts through `/login` using Anthropic’s Claude Code OAuth client. It stores rotating access and refresh tokens in `$ALBEDO_HOME/auth.json` under `anthropic` (mode 0600), with refresh under `auth.lock`. The fixed `http://localhost:53692/callback` loopback redirect must be available; a pasted callback URL also works. Choose a Claude model after signing in; profiles use `extension: "claude"` and `protocol: "chat_completions"`. The picker shows Opus 5.5, Fable 5.1, Sonnet 5, Opus 5, Opus 4.8, and Haiku 4.5 only when the cached models.dev Anthropic catalog contains them. `/reload models` refreshes this availability and the models’ limits. New model families still require a picker-policy update.
Requests go to Anthropic’s Messages endpoint with Claude Code’s OAuth beta, client headers, billing attribution block, identity system block, and tool names. The billing marker is text in `system[0]`, not an HTTP header; its CCH attestation hashes the exact outgoing JSON body. Top-level tool-schema combiners are flattened into an object with their property alternatives and common required fields; nested schemas remain intact because Anthropic rejects top-level `oneOf`/`allOf`/`anyOf`. Replay is saved as provider-neutral chat messages so changing models retains conversation and tool history. This is subscription access, not an Anthropic API-key profile. Anthropic can change or restrict this unofficial client behavior at any time.
## google antigravity oauth
The enabled-by-default `antigravity` extension signs in with the Antigravity IDE's Google OAuth client and sends requests to Cloud Code Assist (`https://daily-cloudcode-pa.googleapis.com`) for Gemini 3, Claude, and GPT-OSS models. `/login` offers **add google antigravity account**; a fresh install can also enter `antigravity` as the provider name. The daemon runs the sign-in through the [sign-in api](#sign-in-api). Its callback is `127.0.0.1:51121/oauth-callback`, which moves to an ephemeral port when 51121 is busy. After the token exchange it finds or provisions the account's Cloud Code Assist project: `loadCodeAssist`, a free-tier eligibility check, and `onboardUser` when the account has no tier yet, with progress shown in the client. An account that Google wants verified fails with the verification url.
Credentials live in `$ALBEDO_HOME/auth.json` under `google-antigravity`, in the one-object-or-array shape: `{type: "oauth", access, refresh, expires, projectId, email?, selected?}`. This is the shape Prime Agent writes, so its accounts can be copied over as they are. The daemon tries the selected account first, then the others in order. Access tokens refresh under `auth.lock` 60 seconds before they expire. A 401 ends the turn with an error ending in `run /login` and marks that access token expired, so the next turn refreshes it; if the refresh token was revoked too, the account is skipped and reads as signed out.
Antigravity profiles use `extension: "antigravity"` and must use `protocol: "chat_completions"`. The extension's upstream translates albedo requests into the Cloud Code Assist envelope, sends it through `openai_api.exchange` (the shared HTTP and SSE runner), and reduces Gemini's stream back into albedo events. Each assistant turn is stored as a Chat Completions message, so history, projection, and other providers read it unchanged. The exact Gemini parts, including thought signatures, travel in `reasoning_details` as an `antigravity.parts` entry and are replayed only to the model that produced them. Output from other models replays as plain text and tool calls; Gemini function calls without a signature get Google's documented `skip_thought_signature_validator` placeholder.
Requests use the `antigravity/hub/<version>` user agent. The version comes from the Antigravity update manifest, or from `ALBEDO_ANTIGRAVITY_VERSION` when it is set. Tool schemas are flattened to the subset Cloud Code Assist accepts: references are resolved, unions keep their first non-null member, `const` and enums become string enums, and dropped validation keywords move into the description.123456789101112131415161718192021222324252627282930313233343536373839404142# commands
every session command is one definition with three faces: the CLI menu, user invocation, and the model's typed `commands` bindings. adding a command adds all three; nothing about a command is reimplemented per surface.
## the catalog
`GET /sessions/:id/commands` lists the materialized catalog: name (the slash spelling), description, the minted python `method`, its `usage` line, declared `arguments`, and the `modelCallable` / `userTurn` flags. the CLI menu renders it (merged with its presentation-only entries like `/login` and `/sessions`), `GET`'s list mints the kernel bindings at boot, and the prompt's `<session_commands>` block summarizes it. all three read the same list.
`POST /sessions/:id/commands` runs one command. the body is `{name, arguments}` with raw invocation text (`{"name": "/model", "arguments": "gpt-5 anthropic"}`) or `{name, args}` with declared names (`{"name": "/model", "args": {"model": "gpt-5"}}`); `clientId` labels a submitted turn. the answer is `{"result": ...}` with the command's JSON value or `{"submitted": true}` when a user invocation submitted its turn.
## contributing commands
`CommandPlugin` in an extension declares static commands; a `ManagedPlugin` contributes dynamic ones through `Managed.commands` (this is how skills registers its slash commands). the type lives in [`command.gleam`](../src/albedo/harness/command.gleam):
```gleamCommand( "/usage", "Show token and cost usage for this session", [Argument("window", "reporting window", False)], True, False, fn(context, _caller, args) { ... Ok(command.Data(value)) },)```
a run executes **outside** the session actor — on the kernel's host-call process or an HTTP request process — and reaches session state only through `Context.state`, whose operations are the `StateOp` variant type in [`command.gleam`](../src/albedo/harness/command.gleam) (the exhaustive case in `session.gleam` answers them). a handler running inside the session actor must never run a command: its state calls would deadlock against itself.
`model_callable: False` keeps a command user-only (`/login`-class commands). a `user_turn: True` command submits its outcome as one user turn when a user invokes it and returns data when the model invokes it — one run, two callers, only the delivery differs. dispatch refuses a model invocation of a user-only command and refuses any turn submission from the model.
## python bindings
the `commands` extension exposes the async `commands` object. at kernel boot the catalog mints one typed method per model-callable command: `commands.model("gpt-5")`, `commands.demo("the args")`. each method's docstring and signature come from the declaration, so `help(commands.<method>)` is the command's help text. `commands.catalog()` returns the current catalog, so a session `/reload` reaches it without a kernel restart; `commands.invoke(name, arguments)` runs any model-callable command by slash name with raw text or a dict. a command whose method name is reserved (`catalog`, `invoke`, `help`), invalid, taken, or added after the kernel booted stays callable through `invoke()`.
argument parsing is shared: leading arguments take one whitespace token each, the last declared argument takes the rest with outer whitespace trimmed and inner spacing exact, so `/fix-lint one two` arrives as `"one two"`.
## v1 commands
- `/model [model] [provider] [effort]` — show the selection (any caller) or switch it (user only, idle session; a model call is always mid-turn, so switching is refused and the model is told to ask the user). A given effort must be one the new model supports; without one, the current effort carries over when the new model supports it.- `/context [section] [page]` — the prepared model request: a summary, or one bounded section page.- `/raise-cap [on|off] [model]` — raise a model's context window to the provider's maximum, or restore its default (user only). Without a state it toggles; without a model it uses the session's. The choice is saved per model in `raisedCaps` and applies to every session on that model from its next request. See [raised caps](models.md#raised-caps).
skills contributes one command per cataloged skill; `/tree`, `/fork`, `/login`, and the other picker-style entries stay presentation-only in the CLI.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697# compaction
Compaction changes only what a request shows the model. The durable transcript, `/tree`, and forks always keep the full conversation.
The default session uses `rolling`. Selecting another strategy (`snapcompact` or `lcm`) through `/extensions` disables the current one in the same reload. A strategy enabled by name, for a session or globally, displaces a default one it did not choose, so a new default strategy cannot break an older choice. A session with the built-in extensions keeps one compaction strategy enabled. The registry also permits a custom host with no compaction strategy installed.
`/compact` compacts now with the session's strategy. `/compact <strategy>` first makes the named strategy the session's own, as `/extensions` would, then compacts with it. Later requests read the projection that strategy saved. For example, `/compact rolling` before switching to a model without image input leaves a text summary that the new model can read.
A strategy returns prepared inputs and an observation for `/context`. The observation describes that preparation. The inspector does not read strategy state. The durable transcript supplies the source rows for each derived view.
## source references
`conversation.load_sources` reads transcript rows with `SourceRef(session, seq)`. `conversation.source` resolves one reference. The sequence is SQLite's append-only transcript identity. A fork copies rows into a new session with new references. LCM copies saved nodes whose entire source range precedes the fork checkpoint and remaps their row and node IDs without a model call. LCM omits a node that crosses the checkpoint, but can reuse its complete child nodes. The branch may summarize the remaining rows if it later reaches the compaction trigger.
Provider projection can combine several durable rows into one model input, split one row into several inputs, or omit a nonportable reasoning item. `projection.for_model_with_sources` records the rows that produced each projected input. `projection.for_model` still returns plain inputs for existing callers. Synthetic extension context and strategy summaries have no transcript reference.
`lcm` uses durable source rows for summary ranges and plain model inputs for its verbatim tail. It preserves every unsummarized user unit and the latest whole user/tool unit. Source references on live projected inputs would let future strategies cut history within those unit boundaries.
Live source references need the sequence IDs from the transaction that commits inputs. The session coordinator can then project those identified entries before preparing a request. Its current commit callback returns only a timestamp, and its in-memory entries have no sequence field. Callers that do not need references can keep the plain-input path. Text or list positions cannot identify rows reliably because messages can repeat and provider projection can change item counts. An excursion marker belongs to its parent session. The branch has different row references, so returning its outcome requires an explicit link to the parent marker.
## stored folds
A strategy reads folds another strategy stored through `context.prior(history)`, never by importing that strategy. It returns `Prior(folds, rest)`: the stored summaries, oldest first, and the history they do not cover. Without stored folds, `folds` is empty and `rest` is the history unchanged. The extension that owns the storage registers a `FoldPlugin`; the runtime applies every enabled provider in registry order, except a provider owned by the active strategy, which reads its own state. `lcm-memory` provides LCM's folds and `snapcompact-memory` the snapcompact archive, so each stays visible after a switch away. `lcm-memory` comes first, so the archive covers history past LCM's folds. `lcm` ignores `prior`.
## lcm
`lcm` implements the source-backed memory part of [Lossless Context Management](https://arxiv.org/html/2605.04050v1). It stores leaf summaries, condensed parent summaries, child links, and a covered source cursor in SQLite. The durable transcript supplies their source rows. The summary tree belongs to one session. A fork copies complete prefix summaries and their links with remapped row and node IDs.
Before the configured trigger, `lcm` sends ordinary history without a summarizer call. At the trigger, it summarizes complete older conversation units in chunks. The request then contains ordered summary nodes with source ranges, followed by a verbatim tail. The latest user/tool unit remains in the tail. Condensation joins older nodes into parents and keeps the children in SQLite. A failed summary does not advance the covered cursor. If a summary exceeds the size of its source, `lcm` retries with a smaller output limit and then stores a short source pointer. The original rows remain available.
The default-enabled `lcm-memory` extension provides four read-only tools. `lcm_list` pages through every stored fold in the current session, including nodes absent from the request view. `lcm_grep` searches literal text in raw history and summaries with paged results and optional node scope. `lcm_describe` returns a node's source range, children, and summary. `lcm_expand` reads bounded pages of the original text rows covered by a node. These tools remain enabled when the session switches from `lcm` to `rolling`. Image bytes remain in the transcript; the text tools show image metadata. The extension implements hierarchical memory. It does not implement the paper's large-file dispatcher, map operators, or subagent-only expansion.
The `lcm` section in `$ALBEDO_HOME/extensions.json` accepts the same setting names as `rolling`:
```json{ "lcm": { "contextWindowTokens": 200000, "triggerPercent": 90, "tailPercent": 25 } }```
`contextWindowTokens` is optional. Without an explicit setting or catalogued window, automatic LCM compaction stays off; `/compact` can still force it. The estimate uses byte counts. The newest whole unit can exceed the available window. In that case preparation reports the limit without discarding part of the unit.
## rolling
`rolling` projects history as four parts, in order:
1. the system prompt and enabled extension context, which are never compacted2. one incremental summary of the older conversation it has already evicted3. a short recap built from recent user messages, quoted verbatim and bounded4. the newest conversation tail, unchanged
It compacts when the estimated request reaches `triggerPercent` of the configured context window, so roughly the last 10% stays free for work. The tail keeps about `tailPercent` of the window. A cut is only made between whole conversation units, so an assistant tool call always keeps its result.
Each rolling compaction combines the previous summary with newly evicted history in a replacement summary. A long eviction is summarized in chunks of at most half the window, with the summary carried from chunk to chunk. The summarizer call has no tools. Rolling writes its summary and cut position only after a successful call. A provider failure leaves the previous projection and the transcript intact. A branch starts without rolling state.
The saved cut counts user messages, not projected items. Provider projection can merge or drop assistant output, but it keeps every user message in order, so the summary still applies after a provider or model change. A fingerprint over the evicted user messages resets the state when the transcript no longer matches. A cut saved as an item count, before this format, is still checked the old way once and then rewritten in the new form.
After a switch from `lcm` to `rolling`, rolling reads the stored LCM summary nodes and the unsummarized tail as its source history. It does not expand covered rows to rebuild the LCM summary. If new assistant or tool rows follow the covered cursor without a new user row, rolling retains their whole conversation unit; that unit can overlap the fold. A later rolling summary can omit details from an LCM node. `lcm_list`, `lcm_describe`, and `lcm_expand` still reach the stored node and its original rows. Switching back to `lcm` uses its saved nodes and the durable transcript; it does not summarize the rolling summary.
## snapcompact
`snapcompact` archives evicted history as rendered bitmap frames that a vision model reads directly, instead of a model-written summary. The frames are X11 8x13 pixel-font text drawn by the local `albedo-render` binary and cached in `snapcompact_frames` by geometry and content. The request contains the frames, then the verbatim tail.
The saved archive is the normalized text of everything before the cut, stored in `snapcompact_archive` with a user-message cut like rolling's. Frames are re-derived from that text for each request, so after a model switch the same archive renders in the new model's frame shape. Recompaction appends newly evicted history to the saved text. Unchanged leading frames keep their cache keys, so the request prefix stays stable.
The archive keeps at most a frame budget, the smallest of:
- the provider's image budget, from oh-my-pi: 90 for `anthropic`, `amazon-bedrock`, and `openrouter`, 200 for `openai`, `openai-codex`, and the Google providers, 10 for `umans`, and 5 for any other provider or a model no catalog knows. albedo's `claude` and `antigravity` providers take 90, since antigravity also serves Claude models.- 60 frames of inline image data (3 MB at about 50 KB a frame), except on `claude`, which uploads images through the files API- 80 frames- `archivePercent` of the window at the estimated cost of one full frame
When the archive exceeds the budget, whole frames between the first frame and the newest are dropped. The first image's caption then states how many characters were dropped.
Other strategies' stored folds stay as text ahead of the frames, so the budget only drops raw rows. `snapcompact` requires `snapcompact-memory`, which keeps the archive readable after a switch: another strategy receives it as text pages through `context.prior`, and the pages stand in for exactly the history the archive's cut covers. `snapcompact-memory` also provides `transcript_grep` and `transcript_read`, which search and page the session's full durable transcript, including history a frame renders illegibly or the budget dropped.
When the catalog reports that the current model reads no image input, `snapcompact` uses rolling's text compaction for that model. The frame archive stays saved for a later model that reads images. A model without catalog modalities is treated as vision-capable.
```json{ "snapcompact": { "contextWindowTokens": 200000, "triggerPercent": 90, "tailPercent": 10, "archivePercent": 20, "maxFrames": 60 } }```
`contextWindowTokens` and `maxFrames` are optional. `maxFrames` replaces the provider caps.
## configure
```json{ "rolling": { "contextWindowTokens": 200000, "triggerPercent": 90, "tailPercent": 25 } }```
Put this in `$ALBEDO_HOME/extensions.json`. `contextWindowTokens` is optional. Without it, `rolling` reads the current model's context window from the enabled [models catalog](models.md). An explicit setting takes precedence. Without either source, `rolling` does not compact automatically and `/context` reports an unknown window.
Estimates are local byte-based approximations, never provider token accounting. Image payloads are sized by their dimensions, not by base64 length, so an attached image cannot fake a million-token conversation.
Image bytes are never copied into the recap or the summarizer prompt; a recent image stays unchanged while its unit is in the tail.
## inspect
`/context` shows the prepared request: its sections in order, their sources, and compaction status. After a provider completes that request, it shows the provider's measured input tokens (and cached tokens when reported); until then, or when usage is omitted, the strategy's local estimate is shown instead. The JSON retains `estimated_input_tokens` separately from `provider_input_tokens` so neither is mistaken for the other. A manual `/compact` sends no ordinary provider request, so its snapshot has only an estimate. See [the context inspector](context.md).123456789101112131415161718192021222324252627# Context inspector
`/context` opens a read-only view of the most recently prepared model request for the current session. It does not prepare history, compact the transcript, invoke a tool, or contact the provider. Before the first provider request, and after a model, workspace, or extension reload changes request composition, the inspector remains explicitly pending until the next request is prepared.
The daemon records the same `Request` value passed to the provider on every model/tool-loop iteration. Recording occurs after extension context and any selected compaction strategy have prepared history, immediately before the provider call. A refresh therefore observes the last actual request; it does not predict the next one.
## Request composition
Sections appear in provider request order:
1. system instructions2. one section per enabled extension context item3. prepared conversation history4. enabled tool schemas
Each section names its source and reports exact item and byte counts for its underlying prepared request value. Byte counts measure UTF-8 request content or the exact encoded provider tool schema, not displayed terminal width. The compaction observation keeps its local `estimated_input_tokens` and method. Once the provider completes that prepared request, `/context` also reports `provider_input_tokens` and, when available, `provider_cached_input_tokens`; the inspector displays the measured input instead of the estimate. Cached tokens are a subset of the reported input, not an amount to add. A provider that omits usage leaves the estimate as the fallback, and a new request starts without the preceding request's measurement. The configured context-window limit is shown only when it is known.
The summary endpoint returns only bounded previews. Full inspectable text is available in bounded 8,000-character pages. Image base64 bodies and opaque provider replay bodies are never copied into inspector pages; the page names each omission while preserving its position and available image metadata. Provider credentials are not part of the recorded request.
## Local API
Authenticated local clients use GET requests only:
- `GET /sessions/:id/context` returns pending state or request metadata and ordered section summaries.- `GET /sessions/:id/context/:section/:page` returns one bounded, zero-based content page.
The `session_context` health capability indicates support. Invalid section or page identifiers return an error without changing session state.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147# extensions
an extension is a named bundle of plugins. it can contribute any number of context, tool, command, managed, or compaction plugins. `python`, `run`, `work`, `schedule`, `files`, `instructions`, `commands`, `skills`, `rolling`, `lcm-memory`, and `remote` are enabled by default; `mcp`, `view`, [`proxy`](proxy.md), and `lcm` are installed and disabled until enabled. plugin contributions compose inside them.
## select extensions
open `/extensions` in a session to inspect installed extensions and enable or disable them. selection is saved per session. enabling a compaction strategy disables the previously selected strategy in the same reload. to replace the active strategy, enable the new one; disabling the only active strategy is rejected. required extensions must stay enabled, and changes require an idle session.
confirming a change reloads that session's workers, context, model tools, python bindings, and host routes. it does not restart the daemon or rewrite the conversation. changing the prompt prefix/tools busts prompt-cache reuse. python variables are saved and restored where possible; values that cannot be saved may be lost. the viewer warns before applying a change.
## installed and enabled
`extensions.Config(installed, default_enabled)` separates installed extensions from their default selection. multiple compaction strategies may be installed, but at most one can be enabled for a session. built-in sessions start with `rolling`; custom hosts can omit compaction. `runtime.start_with_config(database, config)` uses this selection; `runtime.start_with_extensions(database, installed)` enables every supplied extension.
## contribute plugins
interfaces live in [`extension.gleam`](../src/albedo/harness/extension.gleam); built-in composition lives in [`extensions.gleam`](../src/albedo/harness/extensions.gleam).
```gleamextension.Extension( name: "project-context", description: "workspace instructions for the model", requires: [], plugins: [extension.ContextPlugin(load: load_workspace_context)], initialise: fn(_) { Ok(Nil) },)```
`ContextPlugin.load(workspace)` returns `Result(String, String)`. its request-only content is loaded for the current workspace and placed after the system prompt, before conversation history. it is not appended to the durable transcript. model requests keep extension context separate from history compaction.
`ToolPlugin` supplies model-facing tools, python modules, host routes, and tool instructions. model tools declare a definition, an invocation callback, and a recovery callback. recovery must report a stored result or unknown outcome, not replay a potentially side-effecting operation.
`CommandPlugin` supplies [session commands](commands.md): the same catalog drives the CLI menu, user invocation, and the kernel's typed `commands` bindings. duplicate command names are rejected with the other capability collisions.
A command may also be an extension's own **page**: set `page: True` and, run with no arguments, answer a [`page.Document`](../src/albedo/harness/page.gleam) — a title, rows with a badge and tone, the actions available on them, and an optional short glance. The CLI opens a bare `/name` of a page command as that screen and renders it generically: each action runs the same command with `action` = the action's `run` and `details` = the selected row's id followed by any entered text, chosen option, or preset value, and the page is fetched again afterwards. A glance appears in the chat's right margin when the terminal leaves room beside the 100-column body, and as a count in the header when it does not. `/work` is the first page; a skills toggle or an MCP server list would declare theirs the same way, with no client changes.
A command that tells the agent about a user's change uses the `Note(origin, display, text)` state operation: the note waits in the session's queue, reaches the model at its next step if a run is active or ahead of the user's next message if not, and never starts a turn by itself.
`ManagedPlugin` prepares session-owned contributions together: context, tool instructions, tools, python modules, host routes, and commands. preparation returns a close callback. a failed replacement releases its prepared resources and leaves the old selection active; successful replacement releases the old resources after the swap. use this for connections or an immutable catalog shared by context and tools.
`ModelsPlugin` supplies catalogued model facts and provider model lists. `ModelProviderPlugin` declares its models.dev namespace and resolves a tagged saved profile into an `Upstream`: a `stream` function over albedo's request, event, and turn types, plus the endpoint, replay protocol, and an `explain` for failures. A provider with its own wire format encodes an `openai_api.Exchange` and passes it with its own `stream.Reducer` to `openai_api.exchange`, which keeps HTTP, SSE framing, limits, and callbacks shared; `antigravity` does this. `LoginPlugin` supplies a browser sign-in that the daemon runs for every client; see [model authentication](auth.md#sign-in-api).
`ServicePlugin(Service(handle))` serves HTTP from the daemon while its extension is enabled globally (sessions do not select services). Requests to `/<extension>/...` go to `handle(daemon, path, request)` with the path below the mount. The daemon token does not apply there, so a service owns its own authentication; requests carrying an `Origin` header are still refused, so a web page cannot reach a local service that spends your credentials. The daemon's own routes (`health`, `sessions`, `models`, `auth`, `shutdown`) always win. `extension.Daemon` gives the service `home`, `upstream(profile, model, session)` to resolve a saved profile outside any session, catalog `models`, and the daemon's `sessions`. Set `ALBEDO_PORT` in the daemon's environment to pin its port so a service has a stable base url; the default is a free port chosen at start. Built-in dependencies keep these layers explicit: `codex -> openai -> models`. See [models](models.md) and [model authentication](auth.md).
`CompactionPlugin` supplies a history strategy; `FoldPlugin` supplies stored folds any strategy reads through `context.prior`; see [compaction](compaction.md). one enabled compaction strategy owns the request-history view in a built-in session. a strategy receives chronological history and must preserve tool call/result associations; it must not replace the durable transcript.
## extension settings
optional `$ALBEDO_HOME/extensions.json` (default `~/.albedo/extensions.json`) holds named extension settings separately from provider credentials and session selection. settings are read when preparing a worker; changing a file does not mutate an existing session composition. missing sections use extension defaults. invalid settings fail preparation without replacing the live worker.
## python bindings
an explicitly registered python module exports `setup(api)`, which may be a plain function or an async function (awaited at kernel boot, so a plugin can fetch its catalog through `api.host` before the handshake):
```pythondef setup(api): def double(n): return n * 2 return {"double": double}```
packaged short names resolve to `albedo_plugins.*`; dotted names select installed packages. this is the python tool-plugin module namespace, not the name of the enclosing extension. imports happen before the workspace enters the import path.
- `setup(api)` runs per kernel; duplicate and reserved bindings are rejected.- `api.on_shutdown(close)` registers cleanup.- `api.background_handle(Type)` marks a returned background handle.- `api.capture(id)` retains bounded output.- `await api.host("namespace.method", args)` calls an enabled host route.- `api.attach_image(data)` attaches PNG, JPEG, or WebP bytes to the running cell's result.
## images in tool results
a cell returns images with `show_image(bytes_or_path)`, or a plugin with `api.attach_image(data)`: at most 4 images and 5 MiB per cell, and only while the cell runs. a background task a finished cell left behind has no result to carry one, so it gets an error. the harness reads each image's header again before sending it; one it cannot read is named under `image_errors` in the result text and not sent.
on the wire, responses sends the images inside `function_call_output.output`, after the text. chat completions tool messages take text only, so after a run of tool results one user message carries their images, each group labelled with its call id. history keeps the images on the tool result itself, so compaction never mistakes that message for a user turn. the model must accept image input; nothing checks that yet.
interfaces: [`PythonApi`](../priv/python/albedo_api.py), [`work`](../src/albedo/harness/work.gleam), [`compaction strategy`](../src/albedo/harness/compaction.gleam).
## run
`run(program, *args, cwd=, env=, stdin=)` starts one program, without a shell, as a supervised job that owns its process group; output stays on the handle (`job.tail()`, `output.read(job.id)`) and finished handles stay addressable in `jobs`. a job that finishes with its result unread wakes the session: the kernel reports the completion through the `jobs` host route, and the session submits a user turn naming the job, its exit, and how to read it, so the model never polls or awaits a handle just to learn it finished. awaiting the job, reading its result, or stopping it retires the wake. a busy session is retried, not queued in gleam: the kernel retries the notice until the run ends or the result is read. live jobs pin the kernel against the idle sweep — a detached session is not released while background work still runs, local or remote, because releasing it would kill the job and the wake it owes. remote `run` jobs wake the same way — the remote kernel's host calls relay over the ssh connection into this session's route, and the notice names the host — and a mirror read (`rem_job.tail()`) tells the remote job it was read through one `poll` round trip.
there is no shell, so shell syntax has python spellings: `cd dir &&` is `cwd=`, `NAME=value` is `env=`, `| tail -n N` and `| head -n N` are `job.tail(lines=N)` and `job.head(lines=N)`, stderr always joins stdout, and chains, loops, and globs are python over the job's output. `a | b` is `run(a…).pipe(b…)`, the same as `run(b…, stdin=job)`: the writer's bytes stream into the reader exactly, a full reader pauses the writer, and once every reader has closed its stdin the kernel stops reading the writer, so its next write fails as it would into a closed shell pipe (`run('yes').pipe('head', '-2')` ends). the writer still captures its own output; piping retires its wake, and the reader's notice names the whole pipeline. a job that already wrote feeds what it retained first; one that wrote past the 1 MiB it retains cannot be piped after the fact. `run("bash", "-c", …)` and its spellings are refused, and so is a cell's own `subprocess`, `os.system`, `os.exec*`, `os.posix_spawn`, or `asyncio.create_subprocess_*` — an audit hook in `albedo_shell` checks whether the nearest non-stdlib frame is cell code. each refusal carries the `run()` call the line means when it is simple enough to read (`cd cli && go test ./... | tail -5` → `job = await run('go', 'test', './...', cwd='cli')` then `job.tail(lines=5)`), so the error is a copy-paste answer rather than a wall. this is guidance, not a sandbox: plugins and libraries a cell calls still spawn, and `exec` of a compiled string is not cell code.
## view
optional. `await view_code(path, start_line=1, end_line=None, *, columns=79)` renders a range of one file as syntax-highlighted PNG pages, and `await view_diff(path=".", start_line=1, *, staged=False, columns=80)` renders `git diff` the same way, without line numbers since a diff's own lines are not the file's. both return the pages with the cell's result (see [images in tool results](#images-in-tool-results)). the instructions ask the model for a final review pass before it reports code work as done: `view_diff` for every change, `view_code` for context and untracked files, checked against the surrounding style, for repetition an existing helper should carry, and for leftovers; fix, then view again.
the whole file is highlighted, so a range starting inside a comment or string is colored correctly. lines wrap at `columns`, with an arrow in the gutter on each continuation row; tabs expand to 4, wide characters take two cells. up to 4 pages of at most 80 display rows, split evenly at line boundaries, so wrap-heavy code fits fewer source lines per page. the returned text names each page's lines and the call that continues. `view_diff` leaves out files git does not track and asks git for a repository first, so outside one it says so.
rendering is `albedo-render`, a rust binary in `native/render` (arborium's tree-sitter grammars, bundled JetBrains Mono), run as a supervised `run` job. `native/render/install.sh` builds it into `priv/bin`; `PATH` also works. a language without a compiled-in grammar renders unhighlighted.
## work
`work` is a revision-checked ledger shared by humans and agents. The model uses `await work.list/get/create/update/delete`. People use `/work`, its page: `a` adds an item, `e` renames, `d` marks done, `s` sets a status, and `x` removes one (an item with sub-items stays). Typed forms work too: `/work add <title>`, `/work edit <id> <title>`, `/work status <id> <status>`, `/work remove <id>`. Every change a person makes is queued as a note for the agent. Open items appear beside the conversation when the terminal is wide enough.
## files
see [the files extension](files.md): bounded reads, guarded exact edits, and ripgrep search that runs as a supervised `run` job instead of a raw subprocess.
## models
see [the models catalog](models.md): the cached models.dev catalog that supplies context windows, modalities, and provider endpoints to compaction and `/context`.
## commands
see [the commands extension](commands.md) for the full contract. `CommandPlugin` contributes static commands and `Managed.commands` dynamic ones; runs execute outside the session actor and reach state only through the registered state seam.
## mcp
see [the mcp extension](mcp.md) for connecting Model Context Protocol servers: configuration, credential scope, namespaced tools, and connection lifecycle.
## webhooks
Optional. A signed HTTP hook wakes one persistent session, with an opt-in setting for agent self-management. See [webhooks](webhooks.md) for setup, signing, retries, and security boundaries.
## remote
the `remote` extension boots this session's Python kernel on a remote host over one SSH connection, so every harness tool is callable on it, and it is enabled by default. `rem = await remote.connect()` stages albedo's python bundle on the target, starts the kernel there through SSH with `ControlMaster`/`ControlPersist` (one multiplexed TCP connection: no re-authentication per call), and answers the remote kernel's host-route calls against this session's daemon.
machine tools run on the remote kernel: `rem.run(program, *args)` starts a supervised job on the host immediately and synchronously, like local run — the handle exists right away, `job.tail()`, `job.id`, `job.exit_code`, `job.duration`, and `job.timed_out` answer synchronously from the mirrored output stream, `await job` waits for completion, and `await job.stop()` stops it. pipes run entirely on the host, chained before the one await: `job = await rem.run("journalctl", "-u", "nginx", "--no-pager").pipe("rg", "error")`, then `job.tail(lines=40)`. `tail(lines=)` and `exit_code` answer from the mirror; other job methods, like `head(lines=)`, are remote calls to await, and properties such as `job.pipeline` do not cross. session tools run where the daemon runs: `await rem.work.*` and `await rem.skills.*` relay over the connection to the local daemon's ledger and catalog. both kernels load the same content-hashed bundle, so a tool's remote shape matches its local one. every other call returns a reference that settles on its first await: values cross as real objects — dataclasses and list subclasses are rebuilt, so remote output prints exactly like local output — and a result that cannot cross comes back as a live reference whose methods are further remote calls. references passed back into remote calls stay references, not copies, including ones still in flight. one rule remains: a call whose local counterpart is synchronous (`rem.files.read`) still needs `await`, because the value itself crosses the network; handles and their state never do.
the target resolves per call from a `host=` argument, then `remote.configure(host, remote_cwd=None)`, then `$ALBEDO_SSH` (`user@host[:/path]`), then the `remote` section of extensions.json:
```json{ "remote": { "host": "deploy@box", "remoteCwd": "/srv/app", "python": "python3" }}```
a bare host asks the remote for its `pwd` once. if the host is reachable but its kernel cannot boot (no python, staging failed, bad handshake), `connect()` still returns and prints a warning: the connection is degraded to SSH command mode, where only `rem.run(program, *args, cwd=, env=, stdin=, timeout=300)`, `rem.shell(command, cwd=, env=, stdin=, timeout=300)`, `rem.read(path)`, and `rem.write(path, content)` answer and everything else raises. here `stdin` accepts only text or bytes, not a file or another job, and `.pipe()` is unavailable. The connect warning shows the degraded-only escape hatch `rem.shell('git log --oneline | rg fix')`, which invokes `bash -c` on the host for pipes or other shell syntax. `rem.shell()` does not exist in kernel mode. Neither the quoted-argv `rem.run()` fallback nor `rem.shell()` can offer process-group supervision, so stopping or timing out an ssh client does not prove the remote program stopped. an unreachable host fails `connect()`. connections are scoped: a lost SSH channel invalidates the connection rather than silently reconnecting, and `await rem.close()` ends it. connections run under `BatchMode=yes`, so key-based auth is required.
## skills
see [the skills extension](skills.md) for discovery paths, metadata-only startup context, and on-demand reading of actual `SKILL.md` files and resources. skills requires python: every cataloged skill is a [session command](commands.md) with a typed `commands.<method>(...)` binding, while users invoke the listed slash command. both resolve the same session catalog and the same activation. the model caller receives instructions as data without submitting another turn; the user caller submits one activation turn. installing or reading a skill does not run its scripts or import its code automatically.
## rolling compaction
`rolling` is the enabled-by-default compaction strategy: summary, recent-user recap, then a verbatim tail, triggered by remaining context. see [compaction](compaction.md) for its contract, settings, and state handling.
## snapcompact
`snapcompact` is an alternative compaction strategy: evicted history as rendered bitmap frames under per-provider image budgets. it requires `snapcompact-memory`, which is enabled by default, keeps the archive readable as text folds after a switch, and provides `transcript_grep` and `transcript_read`. see [compaction](compaction.md).
## lcm compaction
`lcm` is an alternative compaction strategy with source-backed hierarchical summaries. enabling it disables `rolling` in the same reload. `lcm-memory` keeps `lcm_list`, `lcm_grep`, `lcm_describe`, and `lcm_expand` available after a switch back to `rolling`, so stored folds remain accessible even if a rolling summary omits them. see [compaction](compaction.md) for the handoff and retrieval limits.123456789101112131415161718192021222324252627282930313233343536# files extension
`files` gives the model file vocabulary inside the Python session: bounded reads, one directory listing, exact edits, and search. It is enabled by default and depends on `python` and `run`.
Nothing here spawns a process directly. When ripgrep is installed, search runs as a supervised `run` job, so every child belongs to a job's process group and is ended with it. Without ripgrep the same calls fall back to pure Python. The internal search job is awaited and forgotten when it finishes; it does not appear in `jobs` or wake the session. If the cell is cancelled mid-search, its job is stopped before the cancellation continues.
```pythonfiles.read("src/app.py", start_line=40, end_line=80)files.ls("src")files.edit("src/app.py", "def handler(request):", "def handler(request, *, trace):")await files.find("prepare_history", "src", glob="*.gleam")await files.paths("integration")files.write("notes/plan.md", "...")```
## read
`read(path, start_line=1, end_line=None, *, limit=None, max_chars=16000)` returns numbered lines. `limit` is a number of lines; `max_chars` is a separate character budget that keeps a huge window out of the context. A window stopped by either ends with which one stopped it and the line to resume from, so a large file is read in explicit steps rather than truncated silently. Lines are never shortened, and the numbers are the ones `edit(line_hint=)` accepts.
## edit
`edit(path, old_str, new_str, line_hint=None)` replaces one exact, unique occurrence:
- The file is read with its identity (device, inode, mode, size, mtime), written to a temporary file with the same mode, and published with `os.replace` only while that identity still matches. A concurrent change fails the edit instead of overwriting it.- A string that does not appear reports the closest candidates with line numbers and a similarity score.- A string that appears several times changes nothing and lists every occurrence's line range with numbered context. Retry with `line_hint=<a line inside the range you want>`, or widen `old_str`. A hint only chooses between exact occurrences; it never moves the edit elsewhere.
`write(path, content)` replaces a whole file and creates parent directories, for new files rather than edits.
## search
`await find(pattern, path=".", glob=..., context=0, max_results=50, literal=False, case_sensitive=None, hidden=False)` searches contents and returns rows that print as `path:line: text`. `path` may be a list of paths. `context=N` adds up to N lines around each match, printed grep-style as `path-line- text` and marked `context=True`; `max_results` counts matches, not context lines. Slicing or indexing before the await applies to the rows, so `await find(...)[:10]` reads as intended. `await paths(pattern=None, path=".", glob=...)` searches file names. Both bound their results and say when the list was cut.
## await
`read`, `ls`, `edit`, and `write` return their result immediately, and that result may also be awaited: `files.read(path)` and `await files.read(path)` are the same call. `find` and `paths` run a background search, so they must be awaited; using one without `await` prints, or raises on iteration, a message saying so rather than a coroutine.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657# MCP extension
Albedo's `mcp` extension connects one session to configured [Model Context Protocol](https://modelcontextprotocol.io) servers and advertises their discovered tools, resources, and prompts as ordinary model tools. It is installed but disabled by default: enable it from `/extensions`, for every session or (after `s`) just the current one.
The client is [`barrel_mcp`](https://hex.pm/packages/barrel_mcp), so stdio and streamable HTTP transports, protocol negotiation, pagination, and cancellation come from a maintained MCP implementation. Albedo owns configuration, credential scope, naming, and lifecycle.
## Add servers from albedo
`/mcp` lists configured servers. `n` opens one form for a new server:
- **transport**: `http` (default) or `stdio`, switched with ← →.- **url** for HTTP, or **command** for stdio, written as you would type it in a shell (`npx -y @modelcontextprotocol/server-filesystem "/srv/my docs"`); quotes are honoured but no shell runs.- **name**, suggested from the URL host or the command's package until you edit it.- **bearer token** and one custom **header** for HTTP, or `KEY=value` **env** entries for stdio. These are optional, masked while typed, and stored in `mcp-credentials.json` (mode 0600), never in `extensions.json`.
Nothing is written until the form is saved with ctrl+s (or enter on its last field). The server and its credentials are saved together and the session reloads once; if the server cannot connect, both files are restored and the form stays open with the error. `enter` edits a server in the same form (a blank secret keeps the stored one, `-` removes it), `d` deletes a server and its credentials, and `e` re-enables a server switched off with `"enabled": false`.
## Configure servers
Servers live in the `mcp` section of `$ALBEDO_HOME/extensions.json` (default `~/.albedo/extensions.json`), never in `config.json` with provider credentials.
```json{ "mcp": { "servers": { "docs": { "type": "stdio", "command": "/usr/local/bin/docs-mcp", "args": ["--root", "/srv/docs"], "cwd": "/srv/docs", "env": { "DOCS_TOKEN": { "env": "MY_DOCS_TOKEN" } } }, "search": { "type": "http", "url": "https://mcp.example.com/mcp", "bearerTokenEnvVar": "MY_SEARCH_TOKEN", "enabledTools": ["search"], "callTimeoutMs": 30000 } } }}```
Secrets are named, never inlined: every `env` value and HTTP header reads one process environment variable of the daemon, and a missing variable fails preparation instead of connecting without it. `enabled`, `enabledTools`, `disabledTools`, `startupTimeoutMs`, and `callTimeoutMs` are optional.
A stdio server is launched as an exact executable and argument vector through Albedo's own launcher, with no shell, a scoped working directory, discarded server stderr, and an environment reduced to `HOME`, `PATH`, `TMPDIR`, and the values you named. HTTP servers may use `https` or `http`, including trusted LAN and tailnet addresses. Plain HTTP provides no transport encryption by itself: use it only on a network you trust (a tailnet encrypts its own traffic). Credentials are sent to the configured endpoint, so verify its address before adding auth. URLs carrying userinfo or a fragment are rejected.
## Tools and lifecycle
Discovered capabilities are advertised as `mcp_<server>_<operation>_<hash>`, so two servers that expose the same tool name stay distinct and stable. Resources and prompts, when a server offers them, appear as one `read_resource` and one `get_prompt` operation per server. The catalog and its connections belong to the session composition: enabling, disabling, or reloading the extension prepares a replacement first and closes the previous connections only after a successful swap.
A server that cannot start, initialize, or be discovered fails the whole preparation. The session keeps its previous working composition and reports the unavailable server; capabilities are never silently dropped. Disabling the extension closes every connection and terminates each stdio subprocess.
Tool calls may have side effects, so a failed or interrupted call is never retried automatically. Transport loss is reported as an unknown outcome, which the model must inspect before acting again.
Remote servers are untrusted input. Tool descriptions, resource contents, and prompt text are data, not instructions, and their schemas are bounded before they reach the model.