Skip to main content

Configuration Reference

llmenv's central configuration is a YAML file at ~/.config/llmenv/config.yaml. Project-specific configuration lives in .llmenv.yaml marker files inside each project (see Project markers).

The config directory is resolved in this order:

  1. $LLMENV_CONFIG_DIR, if set.
  2. The platform config dir (~/.config/llmenv on Linux/macOS).

Top-level blocksโ€‹

BlockShapePurpose
cache:mapLocal materialization cache + sync behavior
scope:map of listsNetwork / host / user scope definitions
capabilities:mapEngine-neutral permissions, hooks, plugins (+ native_* overrides)
native:map (per engine)Opaque per-engine passthrough for keys no feature models
bundle:listEnvironment-variable + file bundles
mcp:listMCP server declarations
lsp:listLSP server declarations (Crush + Claude Code; no-op on engines without an LSP surface)
features:mapFeature flags; holds memory: (ICM backend topology), codebase_memory: (codebase-memory-mcp integration), throttle: (usage throttling), upgrade: (upgrade release track), read_once: (re-read deduplication), task_tracker: (in-engine task tracker), slippage: (behavior-drift guardrails), repeat_detect: (loop detection), cd_guard: (Bash cd advisory), and context_mode: (context-mode built-in)
session_log:mapSession-activity logging: local JSONL file and/or ICM transcript
statusline:mapWidget layout, formatting, and colour config for llmenv statusline
state:mapDurable per-tool state relocation (survives cache folder churn)
marketplace:listPlugin marketplaces (git URL or local path)
plugin-collection:listNamed bags of plugins, selected by tag
skills:listFirst-class skill declarations, selected by tag (same model as lsp:)
output_styles:listClaude Code output styles, selected by tag (added in v3.10.0; see output_styles:)
host:mapHost name โ†’ reachable address (used by features.memory:)
init:mapSettings seeded into new materialized folders by llmenv init
disabled_engineslistEngine IDs to skip during materialization (#562)

All blocks are optional. Scopes (except project), bundles, MCP servers, plugin collections, skills, LSP servers, and the memory backend all share the same selection model: they activate when one of their tags is in the active tag set.

disabled_enginesโ€‹

A list of engine IDs whose adapters are skipped during materialization, even when the engine's binary is on PATH (#562). Uses the underscore form (e.g. claude_code, crush, opencode), matching the native.<engine> and --engine flag convention.

disabled_engines:
- crush # skip Crush materialization even when `crush` is on PATH
- opencode # skip opencode materialization even when `opencode` is on PATH

cache:โ€‹

cache:
cache_dir: "~/.cache/llmenv" # where materialized configs are stored
sync_interval_minutes: 15 # how often `export` pulls config from git
cache_retention_hours: 168 # GC retention window (default: 7 days)
remote_sync: true # enable remote git ops (fetch, pull, push)
hashing: normal # loose | normal | strict (default: normal)

Defaults: cache_dir = ~/.cache/llmenv, sync_interval_minutes = 15, cache_retention_hours = 168, remote_sync = true. Set cache_retention_hours to null to disable age-based GC.

remote_sync โ€” toggle background remote git operationsโ€‹

When enabled (default), llmenv fetches and pulls config from git on export.

Set to false to disable background remote git operations (the throttled pull that runs during llmenv export). Manual commands like llmenv sync and llmenv plugin-sync are unaffected โ€” they always perform remote operations regardless of this setting.

Useful when your SSH credential helper (e.g. 1Password's SSH agent) is locked and an SSH askpass prompt would hang terminal-based git operations during startup:

cache:
remote_sync: false

hashing โ€” how materialized folders are namedโ€‹

A single dial with three positions. The folder path is:

ModeFolder layoutWhen to use
loose<adapter>/<shape>/Maximum cache reuse across upgrades
normal (default)<adapter>/<version_major>/<shape>/Balanced: stable across minor/patch releases, churns on major bumps (added in v3.10.0; before that, <version_mm>/minor bumps)
strict<adapter>/<VERSION_TAG>-<content_hash>/Maximum isolation; new folder on any input change

shape is a 12-hex SHA-256 over the active tags โˆช enabled bundles. Config edits always re-render into the same folder in loose and normal modes, so a running agent only loads them when you relaunch it (llmenv check-stale nudges you on the next SessionStart). The folder is the agent's live config dir for the whole session, so in-session state llmenv doesn't own โ€” Claude's runtime files, third-party plugin state โ€” is preserved across re-renders. settings.json is merged rather than clobbered, so a plugin's self-registered hooks survive โ€” but only while the plugin stays enabled. Disabling or removing a plugin purges a hook traceable to that plugin's own resolved install directory (added in v3.11.2); a hook a plugin registers somewhere else entirely is not yet covered.

Each materialized folder carries a .llmenv-manifest.json dotfile (the content hash + the files llmenv owns). It is what check-stale/doctor use to detect drift and what re-renders use to clean up files llmenv no longer renders without touching foreign state.

scope:โ€‹

Scopes are conditions on the current environment. When a scope matches, its tags join the active set. Four kinds are declared here; the fifth (project) is discovered from marker files โ€” see Project markers.

scope:
network:
- id: office
match: { gateway_mac: "aa:bb:cc:dd:ee:ff" }
tags: [office]
host:
- id: workstation
match: { hostname: "work-mbp" } # case-insensitive
tags: [workstation]
user:
- id: me
match: { user: "alice" } # matches $USER
tags: [me]
content:
- id: rust-project
match: { glob: "*.rs", depth: 2 } # depth omitted = unbounded
tags: [lang-rust]

Each scope has an id (used in diagnostics and LLMENV_ACTIVE_SCOPES), a match block, and a tags list.

  • Network match fields: gateway_mac, ssid, cidr. A scope that sets more than one field is active only when every field matches. A scope that sets none never matches. See Network match fields.
  • Host match field: hostname (compared case-insensitively).
  • User match field: user (exact match against $USER).
  • Content (added in v3.3.0) match fields: glob (matched against paths relative to the working directory) and depth (optional; caps how many directories deep the search descends โ€” omit for an unbounded search). Unlike network/ host/user, which check environment facts (network gateway, hostname, $USER), content scopes activate based on what files exist in the working tree โ€” e.g. gating a bundle's hooks to only fire when *.rs files are present. All active content scopes are evaluated together in a single directory walk, so adding more content scopes doesn't multiply the cost of the walk.

There is no scope.project block. Project scopes come from .llmenv.yaml markers, not config.yaml.

Precedenceโ€‹

When scopes of different kinds set conflicting scalar capability values, the order least-to-most specific is network โ†’ host โ†’ user โ†’ content โ†’ project (content joined this ranking in v3.10.0 โ€” see below). List-shaped values concatenate and de-duplicate instead of overriding. Two contributors at the same precedence disagreeing on a scalar's value is a hard error naming both โ€” there's no rank to break the tie, so llmenv fails loudly rather than silently picking one (added in v3.8.0 for every scalar; default_mode always had this).

content ranks just below project: it's an environment signal derived from file patterns incidentally present under the current directory (like network/host/user), not authored intent โ€” but more specific than a bare user-level match, since it's derived from the actual project's file layout. An explicit .llmenv.yaml (project) still outranks it: deliberately authored project config beats an incidental glob match (#845; before v3.10.0, a bundle firing only via a content scope always landed at the lowest rank, unconditionally losing every scalar conflict regardless of how specific its match was).

Network match fieldsโ€‹

(cidr and ssid matching added in v3.12.0; before v3.12.0 only gateway_mac was evaluated and the other two were ignored)

  • gateway_mac โ€” the MAC address of the default gateway. Case-insensitive. Either 1c:0b:8b:e4:5f:94 or the form that macOS arp prints, 1c:b:8b:e4:5f:94, matches (fixed in v3.12.0).
  • cidr โ€” an IPv4 or IPv6 block such as 192.168.1.0/24. The scope matches when any address of a local network interface lies inside the block. Loopback and link-local addresses do not count. The check needs no external tool. A block that is common on other networks, or that a container runtime or VPN also uses (172.17.0.0/16 for Docker), matches there too.
  • ssid โ€” the name of the Wi-Fi network that the machine is associated with. The match is exact and case-sensitive.

llmenv reads the SSID with a platform tool: ipconfig getsummary on macOS, nmcli or iw on Linux, and netsh wlan on Windows. When no tool is available, or the platform hides the SSID, the scope does not match, and llmenv doctor says why. macOS 15 and later print <redacted> to a command-line tool that has no Location Services grant, so an ssid scope never matches there. Use gateway_mac or cidr on such a Mac. On macOS, llmenv reads the SSID of each Wi-Fi hardware port, not of the default-route interface. On Linux, llmenv reads the active NetworkManager Wi-Fi connection, and falls back to iw dev. A network scan list is not used, because a nearby access point controls the SSIDs in it.

cidr and ssid are hints, not proof of location. Any network can hand out the same private block, and any access point can broadcast the same SSID. gateway_mac is harder to copy. A scope whose tags enable permissions, or a cleartext MCP server, should also set gateway_mac.

scope:
network:
- id: home
match: { gateway_mac: "aa:bb:cc:dd:ee:ff", cidr: "192.168.1.0/24" } # both must match
tags: [home]
- id: office
match: { ssid: "Office-5G", cidr: "10.20.0.0/16" }
tags: [office]

capabilities:โ€‹

Engine-neutral capabilities. The same shape is valid here (global) and inside a bundle's bundle.yaml (bundle-scoped); contributors are merged by value shape.

capabilities:
permissions:
default_mode: acceptEdits # default | manual | acceptEdits | plan | auto | dontAsk | bypassPermissions
preset: safe-readonly # added in v3.8.0 โ€” see below
allow:
- { tool: Bash, pattern: "git *" }
- { tool: Read, paths: ["~/code"] }
ask:
- { tool: WebFetch }
deny:
- { tool: Bash, pattern: "rm -rf *" }
hooks:
- event: SessionStart
matcher: "*" # optional
handler: { type: command, command: "./hooks/start.sh" }
- event: PreToolUse
handler: { type: mcp_tool, tool: "my-server:check" }
plugins:
- "superpowers:caveman" # <marketplace>:<plugin>

# Per-engine raw overrides โ€” appended verbatim, never translated:
native_permissions:
claude_code:
allow: ["WebFetch(domain:example.com)"]
native_hooks:
claude_code: { ... } # engine-shaped, opaque to llmenv
native_plugins:
claude_code: { ... }
native_mcp:
claude_code: { ... }
native_model_providers: # added in v3.7.0
opencode: { ... } # deep-merged onto the provider block
native_default_models: # added in v3.10.0
crush: { large: { reasoning_effort: high } } # deep-merged onto the per-role model block
  • permissions.default_mode and permissions.preset are scalars (resolved by precedence); allow/ask/deny are lists (concatenated + deduped).
  • A permission rule has a tool plus either a glob pattern or a list of paths.
  • permissions.preset (added in v3.8.0) expands, at merge time, into a curated set of allow rules โ€” safe-readonly is the only preset today. It covers the read-only CLI tools this project's own bundled rules recommend (rg, ast-grep, shellcheck, shfmt) plus safe read-only git subcommands (status, diff, log, show, blame) and ls, so agents stop hitting a permission prompt for tools the rules themselves told them to prefer. git status/diff/log/show/blame and ls each get both a bare form and a *-suffixed one, since the bare form is their dominant invocation. rg/ast-grep/shfmt (not shellcheck) each ship a deny companion for the flags that turn them from read-only into arbitrary command execution or an in-place write (rg --pre/--hostname-bin, ast-grep -U, shfmt -w) โ€” Claude Code checks deny before allow, so those specific invocations still prompt. fd is deliberately not in the preset despite rg's sibling recommendation in the CLI-tools table: its own escape (fd -x/-X) can hide behind any of its ~11 other boolean short flags in a single clustered token (e.g. fd -Lx cmd .), so a plain glob deny can't actually close it โ€” see #1219. A rule the preset already covers doesn't duplicate one an explicit allow/deny entry also declares. Run llmenv doctor to get flagged when a config allows a legacy tool (grep, find) without also allowing its recommended replacement (rg, fd) โ€” a nudge toward the preset even without adopting it.
  • A hook has an event, optional matcher, and a handler of type command (with command:) or mcp_tool (with tool:). Hook command paths declared in a bundle are bundle-relative and resolved at materialize time.
  • plugins are <marketplace>:<plugin> strings.
  • env is a map of environment variables that llmenv exports for the session. A later contributor overrides an earlier one. A key that starts with LLMENV_, or that llmenv sets itself (LLMENV_STATE_DIR, CLAUDE_CONFIG_DIR), fails validation. A key must match [A-Za-z_][A-Za-z0-9_]*.
  • auto_memory_enabled (added in v1.0.3) is an optional boolean scalar. llmenv renders it to Claude Code's autoMemoryEnabled setting. Leave it unset to let Claude Code decide.
  • native_<feature> maps are per-engine raw fragments emitted verbatim. They are the escape hatch for engine-specific rules with no neutral form. See Engines.

Starting permission mode (Claude Code) (added in v3.11.2)โ€‹

When a Claude Code session starts, the permission mode is determined by this selection order:

  1. The --permission-mode flag or --dangerously-skip-permissions flag on the claude command.
  2. permissions.defaultMode in the user's ~/.claude/settings.json (rendered by llmenv here). Setting this in a project's .claude/settings.json does not workโ€”auto and bypassPermissions require the user file.
  3. Claude Code's built-in default:
    • auto for interactive terminal or VS Code sessions (Claude Code 2.1.283+).
    • default (Manual) for claude -p or the Agent SDK.
    • If disableAutoMode is set to "disable" anywhere in the settings, defaults to default instead of auto.

If auto mode is not available to the session (due to model, provider, organization, or server-side restrictions), the session starts in Manual (default) instead.

Set default_mode: default (or manual) to keep Manual mode if you prefer not to rely on Claude Code's auto mode:

capabilities:
permissions:
default_mode: default # keeps Manual mode even for interactive sessions

With telemetry off, auto mode's safety review runs on Anthropic's server by default from Claude Code 2.1.282. To run it locally instead, set a native override:

capabilities:
native_hooks:
claude_code:
env:
CLAUDE_CODE_AUTO_MODE_SERVER: "0"

See Claude Code's permission modes documentation for the full reference.

effort_level and model_effort (Claude Code)โ€‹

effort_level (added in v2.0.0; changed in v3.12.0) sets the reasoning effort that a Claude Code session starts with. It is a scalar, so the highest-precedence contributor wins.

model_effort (added in v3.12.0) sets effort for one model at a time. Its keys are canonical Claude model IDs, such as claude-opus-5-5. It merges per model ID: the highest-precedence contributor's whole entry for a model wins.

capabilities:
effort_level: high # default for every model
model_effort:
claude-opus-5-5:
effort_level: xhigh # start effort for this model
max_effort_level: xhigh # highest effort this model may use
claude-fable-5-1:
max_effort_level: high

Allowed values:

FieldValues
effort_level, features.slippage.effort_level, model_effort.<id>.effort_levellow, medium, high, xhigh
model_effort.<id>.max_effort_levellow, medium, high, xhigh, max (max means no cap)

Since v3.12.0, any other value fails validation. max as a start level applies to one session only, so set CLAUDE_CODE_EFFORT_LEVEL=max in native.claude_code.env instead. For ultracode, set native.claude_code.ultracode: true. A model_effort entry must set at least one of the two fields. A model_effort key must be the canonical model ID, not an alias such as opus and not an ID with [1m], because Claude Code matches those to the canonical entry itself.

How llmenv renders these into settings.json:

  1. effort_level goes to the top-level effortLevel key. Opus 5, Fable 5.1, and earlier models read that key.
  2. Opus 5.5 and later models ignore the top-level key in the user settings file, and llmenv's rendered settings.json is that file. So llmenv also writes effort_level to modelSettings.<id>.effortLevel for each of those models.
  3. Each model_effort entry goes to modelSettings.<id> as effortLevel and maxEffortLevel. For its model, it replaces the value from step 2.

Claude Code's /effort command also writes to modelSettings. llmenv keeps an /effort save for any model and field that your config does not set. llmenv records what it wrote in settings.json.llmenv-owned-model-settings, next to settings.json. When you remove a value from your config, llmenv removes its own field at the next render, but only if the field still holds the value that llmenv wrote. A config value wins over an earlier /effort save for the same model at the next render. To keep a level that you picked with /effort, do not also set it in llmenv config. A native.claude_code.modelSettings block goes on top of all of this.

The list of models that ignore the top-level key is PER_MODEL_EFFORT_MODELS in src/adapter/model_settings.rs. Each new Claude model that ignores the top-level key must be added to that list when it ships.

advisor_model (Claude Code)โ€‹

(added in v3.12.0; replaces advisor_size)

capabilities.advisor_model sets the advisor model that Claude Code uses, and llmenv renders it to the advisorModel key in settings.json. Use fable, opus, sonnet, or a canonical model ID such as claude-opus-5-5. An alias means the current default model of that family. Any other value fails validation. llmenv checks the shape of the value only. Claude Code and the API check that the advisor ranks at or above the main model. Leave it unset to keep the advisor off. --advisor <model> and /advisor <model> override the setting for a session. See the Claude Code advisor page.

capabilities.advisor_size was removed in v3.12.0, because Claude Code never read the advisorSize key that it rendered. A config that still sets it fails validation and names advisor_model.

model_providers / default_modelsโ€‹

(added in v3.3.0; Crush rendering added in v3.6.1, opencode rendering added in v3.7.0)

Custom or self-hosted model provider endpoints (Ollama, vLLM, LM Studio, a proxy, or an override of a built-in provider), and default-model selection by role. Rendered by the Crush and opencode adapters (api_type maps to opencode's AI SDK package name, e.g. openai โ†’ @ai-sdk/openai-compatible); Claude Code has no multi-provider concept and silently skips these entries, so declaring one in a shared bundle is safe.

capabilities:
model_providers:
- id: ollama
name: Ollama (local)
when: ["home"] # tag-intersected against active scope tags
base_url: "http://localhost:11434/v1" # loopback only โ€” use https:// for any remote host
api_type: openai # wire format: openai | anthropic | google | ...
api_key: "$OLLAMA_API_KEY" # $VAR/!command reference resolved by the target
# engine at its own runtime โ€” never a literal key
models:
- id: llama3.1:70b
name: "Llama 3.1 70B"
reasoning: false
context_window: 128000
max_tokens: 8192
cost: { input: 0, output: 0 }
modalities: ["text"]
default_models:
large: { provider: ollama, model: "llama3.1:70b" }
small: { provider: anthropic, model: "claude-haiku-4-5" } # built-in provider id, unvalidated
  • model_providers[].id is the stable identifier, used as the map key on render and as the default_models[].provider target.
  • when intersects with active scope tags โ€” same selection mechanism as mcp/lsp/skills.
  • api_key/headers are passthrough strings โ€” llmenv writes exactly what you put here into the materialized config verbatim, with no resolution or interpretation of its own. Use a $VAR or !command reference, not a literal key, so the credential isn't committed to your config repo or synced by llmenv sync; the target engine resolves the reference at its own runtime.
  • disabled: true excludes the provider from the resolved set for all engines.
  • Per-model request options (e.g. opencode's reasoningEffort, or a local server's enable_thinking) have no dedicated model_providers[].models[] field, but reach opencode through native_model_providers.opencode (#1007) โ€” its rendered provider.<id>.models is object-keyed by model id, so a fragment can deep-merge onto an existing modeled model's options: native_model_providers: { opencode: { <provider-id>: { models: { <model-id>: { options: { reasoningEffort: high } } } } } }. Crush has no equivalent: its rendered providers.<id>.models is a JSON array, so native_model_providers.crush can only append a model entry, not patch an existing one's fields, and Crush's underlying schema (catwalk.Model) has no generic options field to route extras through even if it could.
  • default_models is a role-keyed map (large, small, or any role name the target engine recognizes) pointing at a { provider, model } pair. provider may reference a model_providers[].id declared alongside it, or an engine builtin (e.g. Crush's built-in anthropic) that llmenv doesn't validate against. opencode only has two default-model slots (model and small_model), so only the large and small roles have a destination there โ€” any other role name is a no-op for that engine.
  • native_default_models.crush (added in v3.10.0) deep-merges onto the rendered per-role models block for Crush's own per-role extras that default_models has no field for (reasoning_effort, think, max_tokens) โ€” opencode has no equivalent slot, since its model/ small_model are bare "provider/model" strings with no room for extra fields. Both model_providers and default_models may also be declared inside a bundle's bundle.yaml, same as at the top level.

native:โ€‹

A per-engine catch-all for top-level keys that no modeled feature owns (e.g. Claude Code's alwaysThinkingEnabled, outputStyle). Keyed by engine name; values are opaque and overlaid onto the engine's config last.

native:
claude_code:
alwaysThinkingEnabled: true

Putting a modeled-feature key (permissions, hooks) here is a hard error โ€” use the native_<feature> siblings under capabilities: instead.

Deleting a key with nullโ€‹

Setting a key to null removes it from the generated config entirely, so the engine falls back to its own default. This works even for keys llmenv itself renders โ€” those are emitted before the native: overlay precisely so you can override them:

capabilities:
auto_memory_enabled: true
native:
claude_code:
autoMemoryEnabled: null # omit the key; let Claude Code decide

The generated settings.json contains no autoMemoryEnabled key at all, rather than "autoMemoryEnabled": null. Nulls nested inside an object value are stripped the same way. (behavior changed in v3.10.0; before that a null on a key llmenv had already rendered emitted an explicit JSON null)

This is a uniform rule across every engine and every write path that overlays a native* catch-all fragment onto already-rendered output: Claude Code's settings.json, Crush's crush.json (native.crush), opencode's opencode.json (native.opencode), and the mcpServers block llmenv merges into the real, persistent .claude.json (native_mcp.claude_code) all strip a null down to a deleted key rather than persisting an explicit JSON null (added in v3.10.0).

bundle:โ€‹

A bundle is a named content set that fires when one of its tags is active, or when a project marker force-enables it via enable_bundles โ€” unless a project marker force-disables it via disable_bundles, which always wins. Its content directory lives at <config_dir>/bundles/<name>/ and its files are merged into the agent config. A bundle's bundle.yaml inside its content directory may declare env: and other capabilities: fields.

bundle:
- name: base
when: [me]
- name: office-tools
when: [office]

A bundle entry with only name and when (no content directory) is valid and participates in tag matching. To inject environment variables, declare them in the bundle's bundle.yaml under capabilities.env.

mcp:โ€‹

MCP servers selected by tag, rendered into the agent's MCP config. Each is stdio (a launch command) or remote (an HTTP/SSE URL).

mcp:
- name: playwright
when: [me]
type: stdio # stdio (default) | http | sse
command: npx
args: ["-y", "@playwright/mcp@latest"]
env:
DISPLAY: ":0"
- name: weather
when: [me]
type: http
url: "https://weather.example.com/mcp"
FieldRequiredNotes
nameyesRegistration name in the agent's MCP config
whennoActivation tags
typenostdio (default), http, or sse
commandfor stdioExecutable to launch
argsnoArguments for command
envnoEnvironment for the launched process
urlfor http/sseRemote endpoint
headersnoHTTP request headers for http/sse servers, such as an auth token (added in v3.0.0)
timeoutnoRequest timeout in seconds; unset uses the engine default (added in v3.0.0)
disabled_toolsnoTool names the engine hides from the model for this server (added in v3.0.0)
disablednotrue excludes the server from every engine (added in v3.0.0)
always_loadnoClaude Code alwaysLoad: true keeps every tool of the server in the prompt, false puts them all behind tool search, unset keeps Claude Code's default. Other engines ignore it (added in v3.12.0)

Claude Code defers the tools of an MCP server behind tool search, so the model must search before it can call one. Set always_load: true for a server whose tools the model needs on most prompts.

See MCP & Memory for the full model.

lsp:โ€‹

(added in v3.0.0)

Language servers selected by tag, rendered into the agent's LSP config. Only engines whose adapter reports supports_lsp() == true render these โ€” today that's Crush and Claude Code; other engines silently ignore lsp: entries, so it's safe to declare in a bundle shared across engines.

lsp:
- name: rust-analyzer
when: [me]
command: rust-analyzer
filetypes: ["rust"] # Crush
root_markers: ["Cargo.toml"] # Crush
extension_to_language: # Claude Code
".rs": rust
init_options:
check:
command: clippy
timeout: 30
FieldRequiredNotes
nameyesRegistration name in the agent's LSP config
whennoActivation tags
commandyesExecutable to launch
argsnoArguments for command
envnoEnvironment for the launched process
disablednoExcludes the server from every engine when true
filetypesnoCrush only: language identifiers the server handles (e.g. ["rust"])
root_markersnoCrush only: filenames/patterns that anchor the workspace root
extension_to_languagenoClaude Code only (required there): file extension โ†’ language id, e.g. {".rs": "rust"}
init_optionsnoOpaque data forwarded verbatim as the LSP initialize handshake options
timeoutnoCrush only: per-server request timeout in seconds

Each engine only understands the fields it needs โ€” Crush ignores extension_to_language, and Claude Code ignores filetypes/root_markers/timeout (it has no equivalents: a single workspaceFolder path and a startup-only timeout, not a request timeout). A server with no extension_to_language is skipped (with a warning) when rendering for Claude Code, since Claude Code's lspServers schema requires it and filetypes language ids don't reliably convert to file extensions.

features:โ€‹

Feature flags. Holds memory: (llmenv's ICM memory backend), codebase_memory: (codebase-memory-mcp integration), throttle: (usage throttling), upgrade: (upgrade release track), read_once: (re-read deduplication), task_tracker: (in-engine task tracker), slippage: (behavior-drift guardrails), repeat_detect: (loop detection), cd_guard: (Bash cd advisory), and context_mode: (context-mode built-in). Additional feature flags may be nested here in future versions.

features.memory:โ€‹

(added in v1.0.0; listen_host added in v1.0.8)

llmenv's own memory backend (ICM). A list of tag-scoped topology entries: each declares one host that runs the daemon and the tag set that activates it (same model as bundles and MCP servers). At most one entry may be active per scope โ€” the resolver errors if two entries' tags match simultaneously. Zero active entries means memory is disabled for that scope.

host:
home-server:
addr: "home-server.local" # IP or resolvable hostname
work-server:
addr: "work-server.local"

features:
memory:
- server_host: home-server # key into the host: table
port: 9092
when: [home] # activates the backend (same model as bundles)
default_topics: ["context-{project}", preferences]
- server_host: work-server
port: 9092
when: [work]
FieldRequiredNotes
server_hostyesKey into host: for the daemon host
portyesPort the proxy listens on / clients connect to
listen_hostnoIP address to listen on (127.0.0.1 for loopback, 0.0.0.0 for all interfaces); default 127.0.0.1
whennoActivation tags
default_topicsnoDocumentation only; preserved across round-trips
mcp_permissionsnoPer-tier permission override for the ICM MCP's tools โ€” see mcp_permissions below
wakeup_max_tokensnoToken budget for the SessionStart wake-up call, 20-4000 (added in v3.8.0)
adaptive_recallnoPer-session adaptive recall, true or false, default true (added in v3.12.0)
always_loadnoClaude Code alwaysLoad for the ICM server, true or false, default true: the ICM tools load with the prompt instead of behind tool search. Set false to defer them again (added in v3.12.0)
default_typenoMemory type, episodic, semantic, or procedural, that llmenv tags on the memories it stores (added in v3.0.0)
default_importancenoImportance, low, medium, high, or critical, that llmenv tags on the memories it stores (added in v3.0.0)
type_importancenoMap from memory type to importance, for a per-type default (added in v3.0.0)
auto_prunenotrue runs llmenv memory prune during llmenv materialize; default false (added in v3.3.0)
retentionnoPer-type retention durations for llmenv memory prune. While set, prune refuses to run โ€” see llmenv memory prune (changed in v3.12.0)
consolidationnoPost-session memory consolidation โ€” see Post-session consolidation below (added in v3.3.0)

wakeup_max_tokens (added in v3.8.0) controls the size of the wake-up pack injected at session start. When unset, llmenv omits the argument entirely and icm's own MCP handler falls back to its hardcoded 200-token default โ€” not the 500 tokens icm's own config.toml may configure, since that file is never consulted on this path. Set it explicitly to request a different budget; out- of-range values fail llmenv doctor/materialize validation instead of being silently clamped.

adaptive_recall (added in v3.12.0) controls how the lifecycle hooks pick memories. With the default true, llmenv keeps a small state file per session and sends each memory one time per model context. The session start sends the wake-up pack and the scope-tagged memories. Each prompt then recalls memories that match the prompt, the files and commands in recent tool calls, the newest tool error, and the last assistant reply, plus memories from related topics. A failed tool call injects memories about that error, and a new subagent gets memories that match its task. After a compaction or /clear, the state resets and the scope-tagged memories go out again. Set adaptive_recall: false to go back to the stateless recall, which sends the same scope-tagged memories on every prompt.

Post-session consolidationโ€‹

(added in v3.3.0; model default changed in v3.11.2; runs on Claude Code SessionEnd since v3.12.0)

After a session ends, llmenv can ask an LLM to distill that session's episodic memories into a few semantic rules and store them back in ICM. (changed in v3.12.0) It reads only the memories of the current project, which it names the same way the session start does. It stores each rule under the topic llmenv-consolidation-<project>, so the rule comes back in that project's recall. Rules stored before v3.12.0 stay under the topic llmenv-consolidation. Before it stores a rule, it recalls the five closest rules in the project topic and in the old topic. It skips the new rule when one of them shares at least 80% of its words and has the same negation words, such as not or never. It is off by default and skips a session with fewer than three memories.

(changed in v3.12.0) The trigger is the session_end hook (Claude Code's SessionEnd). Before v3.12.0 consolidation waited for a post_session event that no adapter sent, so it never ran. The settings come from the active memory: entry, which can be in config.yaml or in a firing bundle's bundle.yaml. The claude-cli backend runs claude -p with disableAllHooks, the default output style, --strict-mcp-config, no tools, no skills, and --no-session-persistence. That child runs no hooks and no MCP servers. It still reads your Claude Code settings and login, so auth from env, apiKeyHelper, or a Bedrock or Vertex provider keeps working. llmenv also sets LLMENV_CONSOLIDATION_CHILD=1 on the child, and llmenv hook-run starts no consolidation under that variable, so the child cannot start consolidation again.

features:
memory:
- server_host: home-server
port: 9092
consolidation:
enabled: true
backend: claude-cli # or anthropic-api
max_rules_per_session: 10
FieldDefaultNotes
enabledfalseTurns consolidation on
backendclaude-cliclaude-cli runs claude -p and works with a Claude subscription. anthropic-api calls the Messages API directly and needs ANTHROPIC_API_KEY
max_rules_per_session10Maximum number of rules stored per session

The anthropic-api backend uses the model claude-sonnet-5. To use another model, set ANTHROPIC_MODEL to a full model ID such as claude-opus-5-5. Claude Code also reads ANTHROPIC_MODEL and accepts aliases such as opus or sonnet[1m]. The Messages API rejects an alias, so llmenv ignores any value that does not start with claude-, logs a warning, and uses the default model. The claude-cli backend does not read this setting.

See MCP & Memory for the topology, security model, and mcp-proxy requirements.

features.codebase_memory:โ€‹

(added in v3.6.0)

First-class integration for codebase-memory-mcp, a local code-intelligence MCP server. A list of tag-scoped entries: each declares the tag set that activates a local instance for a project. Unlike memory:, this always resolves to a local stdio process โ€” codebase- memory-mcp has no remote/network-serve mode โ€” so there's no server_host or port to configure, and multiple entries may be active simultaneously (each is an independent local process, not a shared network resource).

features:
codebase_memory:
- when: [my-project] # activates the server (same model as bundles)
index_path: null # optional override; unset defers to codebase-memory-mcp's own default

(added in v3.8.0) A failed index_repository run's stderr is captured to <index_path (or its default)>/index.log โ€” size-bounded (rotated past 512 KiB, one prior generation kept) and owner-only (0o600) โ€” so a failing multi-minute index build is diagnosable instead of silently discarding its output. (changed in v3.11.1) This directory is llmenv's own diagnostic log location; see below for how it now differs from codebase-memory-mcp's actual cache directory when index_path is unset.

FieldRequiredNotes
whenyesActivation tags; an entry with none is rejected at validate time
index_pathnoOverride the index storage directory; unset leaves it to codebase-memory-mcp's own default (~/.cache/codebase-memory-mcp/), not an llmenv-managed path โ€” see below (changed in v3.11.1)
mcp_permissionsno(added in v3.10.0) Per-tier permission override for codebase-memory-mcp's tools โ€” see mcp_permissions below
mem_budget_mbno(added in v3.12.0) Memory budget for indexing in MB, from 1 to 1048576. Sets CBM_MEM_BUDGET_MB for the server and the SessionStart index. Unset leaves the server's own default
allowed_rootsno(added in v3.12.0) Extra folders codebase-memory-mcp may index, on top of the defaults. Each entry starts with /, ~, or $. See MCP

(added in v3.8.0) The default log directory (<state_dir>/codebase-memory, used when index_path is unset) is created owner-only (0o700). An explicit index_path override is not: llmenv leaves its permissions exactly as its owner set them, so a directory intentionally shared with a codebase-memory-mcp process running under a different uid (a separate service account, or a container with a different uid mapping) keeps working. If you rely on this sharing, secure the directory yourself โ€” llmenv won't tighten or loosen it for you. This permission behavior is unchanged by v3.11.1's CBM_CACHE_DIR change below โ€” it governs llmenv's own log directory, not what gets handed to codebase-memory-mcp as its cache.

(changed in v3.11.1) llmenv sets environment variables for the launched process only when there's something explicit to set:

  • CBM_CACHE_DIR โ€” set to index_path when you configure one; otherwise left unset, so codebase-memory-mcp falls back to its own default cache location. Before v3.11.1 this defaulted to <state_dir>/codebase-memory โ€” the same directory llmenv still uses for its own index.log above. That directory still exists and is still where the log goes, but it's no longer handed to codebase-memory-mcp as its cache: with index_path unset, the actual index now lives wherever codebase-memory-mcp puts its own default (~/.cache/codebase-memory-mcp/), a different location than the log.
  • CBM_MEM_BUDGET_MB โ€” set to mem_budget_mb when you configure one (added in v3.12.0); otherwise left unset.
  • CBM_ALLOWED_ROOT โ€” no longer set at all. Earlier versions pinned it to the current working directory to stop index_repository from being steered outside the intended project; per explicit user direction, llmenv no longer imposes that restriction โ€” codebase-memory-mcp applies its own default scoping instead. This is a real security-posture change, not just cleanup: codebase-memory-mcp will index/read whatever path it's asked to unless you restrict it yourself. If you need the tool scoped to a project root, configure that directly with codebase-memory-mcp's own allow-root/config mechanism โ€” see its docs. This is outside llmenv's scope to impose.

On SessionStart, llmenv fires a fire-and-forget codebase-memory-mcp cli index_repository call for the active project. This both indexes it and registers it with the server's own background auto-watch (auto_watch, on by default upstream), which keeps the index current as files change โ€” llmenv doesn't re-implement reindex scheduling.

llmenv doctor checks that the codebase-memory-mcp binary is on PATH whenever this feature is configured, and flags entries whose tags no scope emits. (added in v3.12.0) It also reports the result of the last index run, including the mem_budget_mb to set after an over-budget stop.

(changed in v3.12.0) The scoping caveat above applies less when codebase-memory-mcp is 0.11.0 or later. At each SessionStart, llmenv records the project root, its own folders, the code-explorer cache, and the allowed_roots entries as allowed roots. A PreToolUse guard denies an index_repository call outside those roots. See MCP.

(added in v3.10.0) Claude Code renders a tiered allow/ask policy for mcp__codebase-memory-mcp__*, mirroring the ICM memory MCP's tiering โ€” read-only/query tools (search_code, search_graph, trace_path, get_architecture, index_status, list_projects, ...) and non-destructive mutations (index_repository, ingest_traces) are pre-approved by default; delete_project and manage_adr (both genuinely destructive โ€” the former irreversibly removes a project's index, the latter is an unversioned overwrite of the project's ADR document with no history) ask. Previously every codebase-memory-mcp tool call prompted individually. Override the default per tier with codebase_memory[].mcp_permissions (same shape as features.memory[].mcp_permissions; see that section for the field reference). A SKILL.md reference (skills/llmenv/references/codebase-memory.md) is materialized whenever this feature is enabled, teaching the agent when to reach for codebase-memory-mcp instead of a plain grep/find sweep.

(changed in v3.11.2) The tiers cover codebase-memory-mcp v0.11.0, which added two read-only tools: get_file_outline and compare_graphs. Both are pre-approved. Before this change they fell through to a prompt on every call.

Two caveats worth knowing before relying on the pre-approved tools:

  • The pre-approved read tools are cross-project, not workspace-scoped. search_code/get_code_snippet take a free-form project parameter and read straight off disk rooted at whichever indexed project that names โ€” not just the one active in the current session. codebase-memory-mcp's own default cache directory is shared across every project you've indexed on the machine, so an agent working in project A can read source out of any other project you've ever indexed, without a prompt. Set a per-project index_path if you need to contain that (the tradeoff: codebase-memory-mcp then can't cross-reference other projects for you).
  • delete_project's prompt is not a complete backstop. index_repository (pre-approved) accepts a name override with no check that the name is already bound to a different project's root โ€” a call naming an existing, unrelated project silently replaces that project's index. Tracked upstream/here as #1331. An index is re-buildable (re-indexing the correct repo recovers it), so this is a nuisance rather than data loss, but it is not gated by the ask tier the way delete_project itself is. (since v3.11.0 a PreToolUse hook denies the name override โ€” see The index_repository name guard.)

features.throttle:โ€‹

(added in v2.3.0)

Usage throttling for an LLM backend. A list of tag-scoped entries (same selection model as memory: โ€” at most one active per scope, resolver errors on two simultaneously active). When an entry is active, llmenv injects PreToolUse and UserPromptSubmit hooks that poll the backend's request budget and sleep a capped, adaptive delay as the budget runs low โ€” keeping the session under the backend's rate limit instead of hitting a hard 429. Each entry names a backend that supplies usage data; umans is the only backend today.

features:
throttle:
- backend: umans # backend that supplies usage data
when: [host-personal-laptop] # activation tags (same model as bundles)
cache_ttl: 30 # seconds a polled snapshot is cached
max_wait: 300 # hard cap (seconds) on any single delay
soft_threshold: 20 # remaining-request level where delays begin
FieldRequiredNotes
backendyesUsage-data backend; currently only umans
whennoActivation tags (an entry with none never activates)
cache_ttlnoSeconds a polled usage snapshot is cached; default 30
max_waitnoHard cap in seconds on any single delay; default 300
soft_thresholdnoRemaining-request level where adaptive delays start; default 20

The delay is always capped at max_wait; the throttle never blocks for a backend-reported penalty window that could be hours long. The umans backend reads ~/.umans/config.json for its endpoint and token. The request connects only to public addresses that pass the SSRF check, it does not follow redirects, and it ignores proxy settings (a proxy would resolve the host itself). The token is never sent to a private or metadata address. Throttling is fail-soft: any error (missing config, network failure) skips the delay rather than blocking the session.

features.upgrade:โ€‹

(added in v3.3.0)

Controls which release track llmenv upgrade uses. The CLI --track flag overrides this on a per-run basis.

features:
upgrade:
track: beta # "release" (default) or "beta"
FieldRequiredNotes
trackno"release" (default) or "beta". release uses the GitHub latest-stable endpoint; beta uses the first non-draft release from the recent list.

features.repeat_detect:โ€‹

(added in v3.7.0)

Engine-neutral repeat-loop detection, on by default (opt-out, not opt-in โ€” omitting features.repeat_detect entirely resolves the same as enabled: true with defaults). Some models โ€” small/local ones especially โ€” can get stuck re-issuing the exact same tool call turn after turn with no progress, or ignoring the task tracker's own "you still have a task in progress" reminder every single turn instead of pausing it. Two independent trackers share this one setting:

  • Tool calls: llmenv tracks the most recent tool name + input per session and, once the same call repeats threshold times in a row, injects an advisory nudging the model to stop and try a different approach. This still fires even when another feature (e.g. read_once) already had something to say about the same call โ€” the two aren't mutually exclusive, since a model can get stuck retrying a call the other feature already denied or warned about.
  • Task-tracker Stop reminder: if features.task_tracker is on and the task tracker's "you still have a task in progress" reminder fires identically threshold times in a row, this appends a pointer to llmenv task wait <slug> "<reason>" โ€” the actual way to silence the reminder while genuinely blocked โ€” instead of just repeating the same "keep working" imperative forever. Past threshold * 3 repeats (added in v3.9.0), the reminder stops firing entirely rather than escalating further โ€” the listed tasks are often none of the current session's own (a different, concurrently active session's), so the task wait pointer is moot advice and the reminder itself had become the loop. A changed task set (a different reminder) resets the streak and re-arms this normally.

Both are warnings, never blocks โ€” llmenv never denies the repeated call or the reminder, so a deliberately re-run command (e.g. re-running cargo test after an unrelated fix) is never blocked. Fires for any adapter/model since the detector lives in the shared hook_run lifecycle layer, not per-adapter code.

features:
repeat_detect:
enabled: false # opt out entirely; omit the block (or `enabled: true`) to keep it on
threshold: 3 # consecutive identical calls/reminders before the warning fires
FieldRequiredNotes
enablednoDefault true.
thresholdnoConsecutive identical calls/reminders before warning; default 3.

features.cd_guard:โ€‹

(added in v3.8.0)

Warn-only PreToolUse advisory for Bash commands that cd, on by default (opt-out, not opt-in โ€” omitting features.cd_guard entirely resolves the same as enabled: true). Claude Code resets the working directory after every Bash call, so a cd โ€” whether standalone or the leading step of a compound command (cd X && โ€ฆ) โ€” silently breaks any following command that assumed the new directory. Prose guidance alone ("prefer absolute paths") doesn't reliably stop this; the advisory mechanizes the reminder instead.

A lightweight heuristic, not a shell parser: it flags any top-level segment (split on &&, ||, ;, |, or newline) whose first word is literally cd. Never blocks โ€” a deliberate cd still runs; the model just gets a one-line nudge toward absolute paths.

features:
cd_guard:
enabled: false # opt out entirely; omit the block (or `enabled: true`) to keep it on
FieldRequiredNotes
enablednoDefault true.

features.context_mode:โ€‹

(added in v3.0.0)

Built-in context-saving support (#490). When enabled, llmenv wires the context-mode plugin automatically โ€” marketplace, plugin registration, durable CONTEXT_MODE_DATA_DIR state dir, and MCP permission grants โ€” replacing the manual plugin-collection / state / native_permissions boilerplate.

features:
context_mode:
enabled: true
FieldRequiredNotes
enablednoDefault false. Set to true to activate the built-in plugin.
mcp_permissionsnoPer-tier permission override for the context-mode MCP's tools โ€” see mcp_permissions below

features.mcp_permissions:โ€‹

(added in v3.6.1)

Every feature-enabled MCP (features.context_mode, each features.memory entry, and โ€” added in v3.10.0 โ€” each features.codebase_memory entry) exposes its tools in three risk tiers โ€” read-only, mutation, and destructive โ€” and llmenv renders one coherent allow/ask/deny policy for them, never a wildcard grant that a more specific rule can silently shadow. The default policy:

TierAction
read_onlyallow
mutationallow
destructiveask

Override any tier by nesting mcp_permissions under the feature. Each key takes allow, ask, or deny; an omitted key falls back to the default above.

features:
context_mode:
enabled: true
mcp_permissions:
read_only: allow
mutation: allow
destructive: ask # or "deny" to block destructive tools outright

memory:
- server_host: home-server
port: 9092
when: [home]
mcp_permissions:
destructive: deny

codebase_memory:
- when: [my-project]
mcp_permissions:
mutation: ask # e.g. to keep index_repository prompting too

An unrecognized value (anything other than allow/ask/deny) is a config error at load time.

features.read_once:โ€‹

(added in v3.3.0)

Reduces redundant context usage: tracks files read via the Read tool within a session and warns or denies re-reads of an unchanged file within a TTL window. Opt-in (disabled by default). Only the Read tool is tracked; other tools are unaffected. A Read call with an offset or limit (a partial read) always bypasses the cache โ€” only whole-file reads are tracked and deduplicated. Fail-soft โ€” any cache/IO error passes the read through silently rather than blocking.

(changed in v3.12.0) The cache of a session resets on /clear and after a compaction, because the model no longer holds the file contents. The same reset applies to the read_before_edit record of features.slippage and to the repeat_detect counter.

features:
read_once:
enabled: true
mode: warn # "warn" (default) or "deny"
ttl_seconds: 1200 # cache TTL in seconds; default 1200 (20 min)
FieldRequiredNotes
enablednoDefault false (opt-in).
modeno"warn" (default) โ€” advisory only, or "deny" โ€” blocks the re-read.
ttl_secondsnoSeconds a tracked read stays cached before it counts as new; default 1200.

features.task_tracker:โ€‹

(added in v3.6.0)

In-engine task tracker (#231): durable, agent-native "what am I working on" state that survives compaction and session restarts. The llmenv task CLI subcommands always work regardless of this flag โ€” it only gates the injected llmenv skill guidance and the SessionStart/Stop lifecycle reminders. Each wip task in a reminder is tagged with the session that started it, and resuming/finishing it (or closing out a fully-done session) is conditioned on the agent recognizing that session as its own โ€” a hook can't tell whether a listed task belongs to this conversation or a different, concurrently running one.

The tracker can be set in the root features: block or in the features: block of a firing bundle's bundle.yaml. The root block wins, then the bundle from the highest-precedence scope. The hooks and llmenv config-context resolve it the same way the adapter does. The same holds for read_once, repeat_detect, slippage, and cd_guard.

features:
task_tracker:
enabled: true
block_engine_task_tools: true # default; set false to opt out
nudges: true # default; set false to stop the in-work reminders
enforce_commit: true # default; set false to stop the commit deny-once
FieldRequiredNotes
enablednoDefault false. When true, also redirects the engine's built-in task tools into this tracker via an auto-injected PreToolUse hook โ€” Claude Code's TaskCreate/TaskList/TaskUpdate, and opencode's todowrite (added in v3.11.0). See Commands for opencode's list-reconciliation rules.
block_engine_task_toolsno(added in v3.10.0) Default true. Set false to keep the tracker's CLAUDE.md fragment and reminders while still letting the engine's native task tools through โ€” e.g. for genuine multi-agent teammate coordination that isn't solo step tracking. Gates opencode's todowrite redirect too (added in v3.11.0). Has no effect while enabled is false.
nudgesno(added in v3.12.0) Default true. Turns off the reminders that fire while work happens: after a workflow skill starts, after several tool calls with no task, and when the agent asks the user a question while a task is in progress. See Commands.
enforce_commitno(added in v3.12.0) Default true. Turns off the one-time deny of the first git commit or gh pr create of a session that has no task in progress.
workflow_skillsno(added in v3.12.0) Skills that trigger the one-time reminder. Default dev-sprint, ship-issue, pre-pr-review, executing-plans, writing-plans. A plugin prefix such as nbl-dev: is ignored when matching.
nudge_afterno(added in v3.12.0) Mutating tool calls with no task before the first nudge. Default 8. Must be 1 or more.
nudge_everyno(added in v3.12.0) Mutating tool calls between later nudges. Default 20. Must be 1 or more.

(added in v3.12.0) While the tracker is on, SessionStart also injects a short statement of the core tracking rules. nudges: false removes that text too. See Commands. llmenv validate rejects a nudge_after or nudge_every of 0, and a workflow_skills entry that is empty.

See Commands for the full llmenv task CLI reference.

features.slippage:โ€‹

(added in v3.3.0)

Guardrails against model behavior drift across long sessions (effort decay, forgetting rules after context compaction). The master switch enabled gates every sub-layer: with it off, no layer runs regardless of its own setting.

All layers are wired to behavior as of v3.11.0. Before that, only effort_level, compact_survival, and diagnose_command had any effect โ€” the other fields parsed but did nothing.

features:
slippage:
enabled: true
effort_level: xhigh # injected into generated engine settings; omit to leave untouched
compact_survival: true # CLAUDE.md fragment: re-read rules after compaction
diagnose_command: true # materializes a /diagnose skill (evidence-first debugging checklist)
rule_reinjection: true # short standing-rules digest on every prompt
read_before_edit: true # deny Write to an existing file not read this session
self_critique: true # checklist appended at Stop
metrics: true # count tool use; store a read:edit summary at session end
explain_before_act: false # opt-in: deny a modifying command with no explanation yet
answer_before_act: false # opt-in: deny a tool call while a question is unanswered
FieldRequiredNotes
enablednoDefault false (opt-in master switch).
effort_levelnoOne of low, medium, high, xhigh (validated since v3.12.0). Used when capabilities.effort_level is unset; see effort_level.
compact_survivalnoDefault true. Merges a short rules fragment into the generated CLAUDE.md reminding the agent to re-read its rules after context compaction.
diagnose_commandnoDefault true. Materializes a /diagnose skill: a structured symptoms โ†’ evidence โ†’ hypotheses โ†’ test โ†’ act checklist.
rule_reinjectionnoDefault true (added in v3.11.0). Injects a short standing-rules digest on each UserPromptSubmit. Deliberately small โ€” it is re-sent every turn. Not sent at session start, where CLAUDE.md already carries the rules.
read_before_editnoDefault true (added in v3.11.0). Denies a Write to a file that exists but hasn't been read this session; Write replaces the whole file. Files that don't exist yet are always allowed, and Edit is left to Claude Code's own guard.
self_critiquenoDefault true (added in v3.11.0). Appends a short checklist at Stop (tests run, anomalies explained, scope finished). Advisory โ€” it never blocks.
metricsnoDefault true (added in v3.11.0). Counts tool calls and stores a read-to-edit summary to memory at session end, folded into the store that already happens there.
explain_before_actnoDefault false (added in v3.11.0). Denies a modifying Bash command when nothing has been said yet this turn. Off by default: a transcript heuristic.
answer_before_actnoDefault false (added in v3.11.0). Denies a tool call while the user's question sits unanswered. Off by default: a transcript heuristic.

features.sandbox:โ€‹

(added in v4.0.0)

Runs llmenv launch <engine> inside a container instead of directly on the host, so a bad delete, a force-push, or an exfiltrated token lands in a throwaway container rather than on the machine. Off by default. Full write-up โ€” the default image, supply-chain hardening, SSH_AUTH_SOCK forwarding โ€” lives under Sandbox in the launch command reference; this is the field-level summary.

features:
sandbox:
enabled: false # opt-in
runtime: auto # auto | docker | podman
image: null # null = llmenv's published default image
forward_ssh_agent: true # bind-mount the host SSH_AUTH_SOCK in
FieldRequiredNotes
enablednoDefault false. --container/--no-container on llmenv launch override this for one invocation.
runtimenoDefault auto (probes PATH for podman, then docker). docker/podman force one engine without probing.
imagenoDefault null โ€” llmenv's published default sandbox image. Any other value overrides it.
forward_ssh_agentnoDefault true. Bind-mounts the host's SSH_AUTH_SOCK into the container when one is running.

llmenv doctor reports whether the configured runtime, the icebreaker binary, and the configured image are all available when this feature is enabled (added in v4.0.0, #1654).

session_log:โ€‹

llmenv records session activity โ€” lifecycle events, the active scope, and (optionally) every prompt/tool call โ€” into a single event stream that fans out to two independent sinks: a local JSONL file and ICM's transcript store, reached over the ICM MCP (never the icm CLI, so this works even when the machine running llmenv isn't the primary ICM host). Either sink can be on without the other; an unreachable ICM backend never blocks the file sink, and vice versa.

session_log:
transcript: # ICM transcript sink (on by default)
enabled: true
level: info
file: # local JSONL file sink
enabled: false
level: info
# max_content_bytes: 16384 # cap per-event content size

Each sink is a mapping with its own enabled and level, so one can capture prompts and tool calls while the other records only lifecycle events.

FieldRequiredNotes
transcriptnoICM transcript sink, recorded via the ICM MCP. Enabled by default; omit the block entirely and you get this sink at info
filenoMirror the same event stream to a local JSONL file; disabled by default
max_content_bytesnoCap each event's content field to this many bytes before it's written/recorded; default 16384

Both sinks take the same sub-fields, plus one each of their own:

Sub-fieldRequiredApplies toNotes
enablednobothTurn the sink on or off
levelnobothMinimum event level (info, debug, trace); default info. debug is what adds tool calls โ€” prompts are already captured at the default info level, see What gets logged
pathnofileOverride the file sink's path; default <state_dir>/session-log.jsonl
retention_daysnotranscriptStale file-sink transcripts on disk are best-effort removed when older than this many days; null = disabled; must be >= 1

Omitting the session_log: block entirely enables the transcript sink at info โ€” ICM transcript logging is on by default. To turn logging off entirely, disable both sinks:

session_log:
transcript:
enabled: false
file:
enabled: false

Breaking change in 4.0 (added in v4.0.0): the boolean form โ€” transcript: true, file: true, verbose: true โ€” is rejected rather than translated. Each sink is now a mapping, and verbose: true became level: debug on whichever sink should capture prompts and tool use, so the two sinks can differ. llmenv names the replacement in the parse error; it does not migrate the file for you.

Breaking change in 3.0: session_log: used to be a bare path string (the file sink only). That form is now rejected with a migration hint โ€” wrap the path in path: under the new table shape.

What gets loggedโ€‹

Two layers, gated by each sink's level:

  • Baseline (level: info, the default, whenever a sink is enabled): lifecycle_start at session start, scope carrying the active tags/bundles/project, lifecycle_end at session end โ€” and also every prompt submission, notification, stop, subagent stop, and pre-compact event, tagged with its role. info is not a summary-only level: it's everything except the two tool-call events below. A user's prompt text reaches whichever sink is enabled (the transcript sink, over the ICM MCP, by default) unless that sink is turned off.
  • Verbose (level: debug): the two tool-call events on top of the above โ€” tool_use (before) and tool_result (after), each tagged with the tool name.

Because level is per sink, a common setup is debug on the local file and info on the transcript โ€” full tool-call detail stays on the machine, while ICM still receives everything at info, prompts included. To keep prompt text off ICM entirely, disable the transcript sink rather than relying on level.

Privacy note: level: debug captures the raw text of every prompt you submit and every tool call's input/output โ€” including any secrets, credentials, or personal data that text happens to contain. That content is written to disk (the file sink) and/or sent to ICM (the transcript sink) unredacted, capped only by max_content_bytes (default 16 KiB, not a sensitivity filter). Treat a session-log.jsonl recorded at debug the same way you'd treat shell history that might contain pasted secrets.

Finding a session laterโ€‹

The scope-header event embeds the same llmenv-tag:<tag> / llmenv-bundle:<bundle> tokens the memory-recall hooks use, so a transcript is discoverable the same way stored memory is. From the ICM MCP:

icm_transcript_search { query: "llmenv-tag:rust" } # sessions scoped to the rust tag
icm_transcript_search { query: "llmenv-bundle:base" } # sessions where the base bundle fired
icm_transcript_search { query: "llmenv session", project: "my-project" } # sessions for one project
icm_transcript_show { session_id: "..." } # full transcript for one session
icm_transcript_stats {} # global session/message counts

icm_transcript_search matches message content only (ICM's FTS index doesn't cover session metadata), which is why the scope header embeds the tokens directly in its content rather than only in structured metadata. The structured metadata (tags/bundles/project/cwd/adapter/llmenv version) is still attached to the session for exact inspection via icm_transcript_show.

statusline:โ€‹

llmenv statusline is a statusline renderer built into the llmenv binary โ€” no separate statusline plugin or binary to install. It reads the engine's session JSON from stdin, llmenv's own stats from the materialized llmenv-status.json, and this config section, then prints one ANSI-styled line per row to stdout. See statusline for how it's wired into an engine.

If config.yaml itself fails to parse, the statusline can't read this section at all and renders an error row instead โ€” see Broken config renders an error row (added in v3.8.0).

statusline:
rows:
- "{model} โ”‚ {context} โ”‚ {budget}"
- "โŽฟ {scopes} ยท {plugins} {config_stale}"
style:
icon_set: auto # auto | nerd | simple | none
widgets:
model:
format: "{short_name} {version}"
style: "bold cyan"
scopes:
format: "{tags}"
max_len: 40
style: "dim"
icons:
config_stale: "โ—Œ"
FieldRequiredNotes
rowsnoOne row template per rendered status line, each a string with {widget_name} placeholders. Default (when statusline: is omitted entirely): a single row, "{model} โ”‚ {folder} โ”‚ {branch} โ”‚ {context} โ”‚ {budget}"
style.icon_setnoauto, nerd, simple, or none โ€” see icon_set below. Default auto
style.colornoMaster colour switch. true (default) lets each widget render its default (or configured) colour; false forces the whole statusline to plain text, on top of the runtime --color/NO_COLOR gate
widgetsnoMap of widget name (model, scopes, ...) to a format / max_len / style override โ€” see the reference table below for each widget's default format and placeholders
iconsnoNamed icon overrides, merged over the resolved icon_set defaults (a name set here always wins)

Each entry under widgets: accepts:

Sub-fieldNotes
formatCustom display template for the widget's own placeholders (see the table below). Only honored by widgets marked "yes" in the Format? column โ€” set on a widget that doesn't support it, it's silently ignored
max_lenMax character length; longer output is truncated with โ€ฆ (U+2026), UTF-8-safe. Default: no limit
styleANSI style string applied to the widget's entire rendered output โ€” see Style tokens below. Every widget has a sensible default colour when this is unset; set it to none (or "") to render that one widget in plain text
displayNamed display mode for widgets that offer presets instead of a free-form format: model accepts short (family only, Opus), version (family + version, Opus 4.8, the default), or full (verbatim display_name); pr accepts number (#834, default) or url (full PR URL, falling back to #<number> when the engine sends none). Overridden by format when both are set; ignored by widgets without a display mode
widthBar cell width for context/cache_usage/usage_5h/usage_7d (default 10). Ignored by other widgets
thresholdsTwo ascending percentages [warn, crit] for value-based coloring. Ignored by widgets without threshold coloring

A row template can also write {widget_name:t} โ€” accepted syntax, but it is a no-op beyond what max_len already does; truncation is driven entirely by max_len, not by this shorthand. A recognized widget with no data to render (e.g. pr with no open PR) renders as an empty string (not an error). An unknown widget name โ€” a typo, or a config still referencing a widget that's since been renamed or removed โ€” renders โš ๏ธ instead, so a misconfigured row is visibly flagged rather than silently vanishing. If every widget in a row renders empty, that row's line in the output is empty too โ€” never a line of bare separator literals; a row with an unknown-widget warning is not empty, so it still prints.

Widget referenceโ€‹

Two widget sources, resolved in this order: engine-sourced widgets read the stdin JSON the engine pipes in every render; llmenv-sourced widgets read llmenv-status.json. A name that matches neither renders empty.

Engine-sourced (from the engine's stdin JSON)โ€‹

All twelve honor format: โ€” set on any of them, it replaces the default layout below.

WidgetFormat?Default outputExampleformat placeholders
modelyes{short_name} {version}Opus 4.8short_name, version, full_name
folderyes๐Ÿ“ + basename of the working directory๐Ÿ“ llmenvbasename, path
branchyes๐ŸŒฟ + git branch name๐ŸŒฟ release/3.xname
pryes#<number> (or the URL in display: url)#834number, url, review_state
contextyesused-context <pct>% + block bar (width cells, default 10), threshold-colored (default [50, 80])35% โ–“โ–“โ–“โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘pct, bar โ€” use either alone, or both, in a custom format
tokensyestotal context tokens, k/m-suffixed10ktotal, input, cache_read, cache_create
budgetyes<used>/<max>, k/m-suffixed35k/200kused, max
durationyesโฑ + elapsed (h+m past an hour, else m+s, else s)โฑ 3h 42mh, m, s, total_ms
cache_usageyesโ†ป + cache-hit <pct>% (no bar by default โ€” unlike context, a high cache percentage is good, so this doesn't threshold-color)โ†ป44%pct, bar (opt-in โ€” e.g. format: "โ†ป{pct}% {bar}")
usage_5hyesClaude.ai 5-hour usage window5h 8% (+4.5) โ‡ก3% โžก23mpct, bar, reset, pace, delta
usage_7dyesClaude.ai 7-day usage window7d 41% โžก3d4hpct, bar, reset, pace, delta
peakyespeak / off-peak billing window (local clock)โ–ณ peak 3h03msymbol, label, countdown

context merges what used to be two separate widgets (context_pct and progress_bar) into one โ€” the percentage and the bar are just two placeholders of the same widget now, so a custom format can show either alone or both together, instead of needing two widget entries in a row template to combine them. Every percentage-based widget (context, cache_usage, usage_5h, usage_7d) shares this same percent/bar rendering backend, so width and the pct/bar placeholders behave identically across all four.

Notes:

  • branch reads the branch from git (.git/HEAD, following a worktree .git-file pointer) resolved from the working directory โ€” Claude Code does not send a branch on stdin for a regular repo. A worktree.branch in the stdin JSON (worktree sessions) takes precedence. Detached HEAD renders empty.
  • model strips a trailing (โ€ฆ) qualifier (e.g. Opus 4.8 (1M context) โ†’ Opus 4.8) and, when the engine sends no separate version, derives it from display_name.
  • Numeric counts (tokens, budget) use k at a thousand and m at a million, dropping a redundant trailing .0 (1000000 โ†’ 1m, 200000 โ†’ 200k, 109200 โ†’ 109.2k).
  • usage_5h/usage_7d require the Claude.ai subscription rate_limits block, which the engine sends only after the first API response in a session; before that (or on API/enterprise plans) they render empty. {reset} is the time until the window resets; {pace} is an over/under-pace indicator (โ‡กN% when usage is ahead of the time elapsed in the window, โ‡ฃN% when behind, empty within ยฑ0.5%). {delta} is the change in used percentage since the last render ((+4.5)), tracked in a small state file under $CLAUDE_CONFIG_DIR/statusline-state/ and rewritten at most once a minute so it reflects real movement, not per-render noise (empty when CLAUDE_CONFIG_DIR is unset). The bar is per-cell (filled in the threshold color, empty dim) with a bright pace-target marker (โ”‚); both windows are threshold-colored by used percentage (usage_5h default [70, 90], usage_7d [60, 80]; override with thresholds).
  • peak is computed entirely from the local clock (Anthropic's peak window is weekdays 05:00โ€“11:00 America/Los_Angeles) โ€” Claude Code sends no peak data on stdin. {countdown} counts down to the window boundary (peak ending, or the next peak starting).
  • pr is colored by {review_state} when the engine sends one: approved green, changes_requested red, pending/review_required yellow. When a PR URL is present and color is on, pr (and the branch widget) render as an OSC 8 terminal hyperlink to the PR โ€” the URL is validated (http/https only, no control chars) before it's embedded.
  • pr self-resolves when the engine sends none โ€” Claude Code never sends a pr field on stdin, so llmenv runs gh pr view for the branch branch resolves (git, from the working directory) and maps gh's reviewDecision onto the same review_state values above. The result is cached for 60 seconds (keyed by repo + branch, alongside the usage_5h/usage_7d state under $CLAUDE_CONFIG_DIR/statusline-state/) so the statusline โ€” re-rendered on every prompt โ€” doesn't shell out on every render. pr renders empty, with no error output, whenever gh isn't installed, isn't authenticated, there's no remote, HEAD is detached, or there's no open PR for the branch. An engine-supplied pr always takes precedence over the derived one. The branch widget's OSC 8 hyperlink uses this same resolution (engine-supplied first, then derived), so the branch text links to its PR under Claude Code too โ€” sharing the same cache, so enabling both widgets doesn't double the gh lookups.
  • Untrusted free-text (model/folder/branch names, PR URL, tags, throttle backend) is stripped of control characters at the point each widget interpolates it, so a hostile directory or branch name can't inject terminal escapes. Widgets emit only their own trusted escapes (colors, hyperlinks).

pr and tokens only expose the fields above โ€” the engine's stdin contract has no PR title or per-output-type token breakdown today, so those aren't invented placeholders.

llmenv-sourced (from llmenv-status.json)โ€‹

All nine honor format:. scopes, plugins, mcps, and throttle are partial exceptions to "from llmenv-status.json" โ€” all four are tag-gated, and the snapshot is only rewritten by llmenv regenerate, so each needed its own fix for the same root cause: a tag added via $LLMENV_EXTRA_TAGS mid-session doesn't take effect until a regenerate, and even a regenerate doesn't help within the same shell, since the materialized cache folder is keyed by the active tag set and a running session's fixed CLAUDE_CONFIG_DIR stays pointed at the folder regenerate no longer writes to.

  • scopes re-reads $LLMENV_EXTRA_TAGS live on every render and unions it onto the snapshot's tags (added in v4.0.0; fixes #1538).
  • plugins resolves entirely from top-level config (no merged-manifest input), so it recomputes live on every render against the same unioned tag set scopes uses (added in v4.0.0; fixes #1547).
  • mcps and throttle need the merged manifest's bundle-contributed data to re-resolve, which isn't available at render time without rebuilding it โ€” the work regenerate does. Instead, each appends a staleness marker ({stale_icon}, gear emoji by default) whenever a live tag isn't already in the snapshot, so a possibly-wrong count is flagged rather than shown silently (added in v4.0.0; fixes #1547).

Every other tag source โ€” host, user, OS, network, project, and content scopes โ€” still only refreshes on the next regenerate, same as every other widget in this table.

A custom format: for mcps or throttle must include {stale_icon} to keep this protection โ€” like every other widget, a custom format fully replaces the default rather than appending to it, so a format that omits the placeholder renders the count with no staleness indication at all.

WidgetDefault formatExamplePlaceholders
scopes{tags}dev ยท rusttags (tag list, joined with ยท; $LLMENV_EXTRA_TAGS is live, every other source is from the snapshot)
plugins๐Ÿ”Œ {total}๐Ÿ”Œ 12total, errors (fully live-recomputed, not snapshot-sourced)
mcpsMCP {total}{stale_icon}MCP 12 / MCP 12 โš™๏ธtotal, errors (both snapshot-sourced), stale_icon (empty unless a live tag has drifted from the snapshot, resolves from the icon set like config_stale's)
icm๐Ÿง  {memories}๐Ÿง  142memories, concepts
cache{prunable}15 MBprunable (humanized), prunable_raw (bytes)
config_stale{stale_icon} staleโš™๏ธ stalestale_icon (resolves from the icon set, gear emoji by default โ€” a statusline.icons.config_stale override applies even without a custom format). Config out of date โ€” relaunch to reload. Renders empty when the config isn't stale โ€” there's no "fresh" variant
throttle{raw}{stale_icon}umans: 45s / umans: 45s โš™๏ธraw ("<backend>: <cooldown_secs>s", snapshot-sourced), cooldown_secs, reason (the backend name), stale_icon (same drift check as mcps's)
session_log{icon} {entries}๐Ÿ“ 8icon, entries
tasksโ˜‘ {done}/{total} (summed across the current project's open sessions, #905); renders empty when no session is open for this projectโ˜‘ 2/5done, total (summed across every session open for the current project), current (title of the task currently wip/waiting among those sessions; empty when none). The default doesn't show current โ€” combine it yourself, e.g. format: "{done}/{total} โ€” {current}"

An unrecognized placeholder inside a custom format string (e.g. {title} on pr, or {count} on scopes) is left in the output literally rather than being stripped โ€” only the placeholders listed above are substituted.

icon_setโ€‹

  • simple โ€” ASCII/Unicode glyphs (*, ~, !, x, #, log, ...)
  • nerd โ€” Nerd Font glyphs (Private Use Area codepoints)
  • none โ€” every icon resolves to an empty string
  • auto (default) โ€” there's no portable way to probe a terminal for a Nerd Font, so auto keys off the LLMENV_NERD_FONT environment variable: set it to 1 or true (case-insensitive) to get Nerd Font glyphs; unset (or any other value) falls back to simple. Set this the same way you'd set it for a shell prompt that has its own Nerd Font auto-detect convention.

Only two icon names are currently consulted by any widget: config_stale (the config_stale widget) and session_log (the session_log widget). The other names resolvable via icon_set (config_ok, icm_ok, throttle, plugin_ok, plugin_error, cache_ok, cache_prunable) are defined and can be overridden under icons:, but no current widget format reads them.

Style tokensโ€‹

style (on a widget, or via finish() internally) is a space-separated list of tokens applied to the widget's entire output:

  • Text attributes: bold, dim, italic, underline, blink, reverse, hidden, strikethrough
  • 16-colour foreground names: black, red, green, yellow, blue, magenta, cyan, white
  • 256-colour: color-<n> (0-255)
  • True colour: #rrggbb hex

Unknown tokens are ignored rather than erroring โ€” a typo in a style string degrades to no styling for that token, not a broken render. With --color never (or, absent an explicit --color, a non-TTY โ€” which is what every host UI's captured-stdout pipe looks like), all style tokens are skipped entirely and widgets render as plain text.

Claude Code / Crush supportโ€‹

Claude Code gets llmenv statusline wired in automatically: the adapter seeds "statusLine": {"type": "command", "command": "llmenv statusline --color always"} into settings.json once, only when that key is absent โ€” a user's own /statusline customization is never overwritten. The --color always is required because Claude Code invokes the command with stdout captured (never a TTY), and --color's default (auto) would otherwise disable every style: widget override in that exact path. Crush has no statusline-hook concept in its adapter today, so statusline: config has no effect there yet (#855 tracks adding it).

state:โ€‹

Durable per-tool state relocation. The materialized cache folder is renamed on every version or config change, so tool state written under CLAUDE_CONFIG_DIR is lost on each churn. llmenv always exports LLMENV_STATE_DIR pointing at a stable sibling directory (no content hash; never garbage-collected). Each entry under state.tools additionally emits one env var pointing a specific tool's state into a per-tool subdirectory of that stable dir.

state:
tools:
- env: CONTEXT_MODE_DATA_DIR # var the tool reads to locate its state
subdir: context-mode # โ†’ $LLMENV_STATE_DIR/context-mode
FieldRequiredNotes
envyesEnv var the tool honors (e.g. CONTEXT_MODE_DATA_DIR)
subdiryesSingle path component under $LLMENV_STATE_DIR (no separators)

env names must be [A-Z][A-Z0-9_]*. A handful of system-reserved names (HOME, PATH, USER, etc.) are rejected.

Inherited Claude Code stateโ€‹

(added in v3.8.0)

Some Claude Code state has no env var to relocate it โ€” it is hardcoded to live inside CLAUDE_CONFIG_DIR. llmenv inherits that state into each newly materialized folder automatically; there is nothing to configure.

StateWhere it livesHow it's inherited
/resume transcriptsprojects/<escaped-cwd>/<session-uuid>.jsonlThe folder's projects/ is a symlink to $LLMENV_STATE_DIR/projects, so every folder shares one transcript store
Prompt history (โ†‘ recall)history.jsonlCopied in from $LLMENV_STATE_DIR when the folder has none
MCP "needs auth" recordmcp-needs-auth-cache.jsonCopied in when the folder has none, so Claude Code doesn't re-probe every OAuth MCP server
OAuth credentialmacOS keychain, service name keyed by the config-dir path; .credentials.json elsewhereCached in $LLMENV_STATE_DIR/auth/credentials.json (owner-only, 0600) and written into a folder that has none
Claude Code's internal session logs (added in v3.9.0)session-logs/ (one file per calendar day)The folder's session-logs/ is a symlink to $LLMENV_STATE_DIR/session-logs, same one-store-shared-by-every-folder treatment as /resume transcripts

Transcripts are linked rather than copied, so a session started under one config hash stays visible to /resume after a config edit or version bump โ€” and there is one store on disk instead of a copy per folder.

history.jsonl is copied instead of linked because a single file rewritten via write-then-rename would replace the symlink with a regular file. A copy is only made when the folder has none; llmenv never overwrites a folder's own history.

On first run after upgrading, transcripts stranded in older hashed folders (from before this behavior existed) are folded into the shared store, with the newest copy of a given session winning.

OAuth credential inheritanceโ€‹

(added in v3.8.0)

Staying logged in needs two separate things. The account identity (oauthAccount in .claude.json) says who you are; the OAuth token says you are authenticated. llmenv has inherited the identity since v1.0.0 โ€” the token is inherited as of v3.8.0, so a config edit or version bump no longer produces a login prompt.

The token is not stored in a stable place by Claude Code on either platform. On Linux and WSL it is .credentials.json inside CLAUDE_CONFIG_DIR, so it dies with the folder. On macOS it is a keychain generic password whose service name embeds a hash of the config-dir path โ€” a different path is a different keychain item, so the keychain is no more stable across hash changes than a file is. llmenv handles both.

Two rules govern the cache, and both exist to avoid destroying a working login:

  • On export: the folder's token is copied into the cache only when the cache is empty or the cached token is dead. A live cached token is never overwritten by whatever a possibly-stale folder happens to hold.
  • On materialization: the cached token is written into the new folder only when that folder has none. A token the folder already holds is never replaced.

"Dead" means the access token is past expiresAt and no live refresh token remains. An expired access token with a valid refresh token is still worth keeping โ€” Claude Code renews it on next use.

llmenv login caches the token alongside the account identity, and writes both into the current folder when one is active.

llmenv doctor reports whether a token is cached and whether it has expired.

On macOS, a keychain lookup that fails for any reason other than "no matching item" (most commonly a locked keychain) surfaces as an explicit error rather than being treated as "no credential stored" (added in v3.8.0).

Third-party MCP server loginsโ€‹

(added in v3.8.0)

Authenticating an OAuth-backed MCP server โ€” Slack, Notion, Linear, and the like โ€” also survives a hash change, and needs nothing extra configured.

Claude Code keeps those tokens under an mcpOAuth key in the same store as the login token, keyed per server as <server-name>|<sha256({type,url,headers})[..16]>. Because llmenv caches and re-injects that store verbatim, MCP tokens come along with the login token automatically. The per-server key includes the server's URL and headers, so changing either invalidates just that server's entry rather than the rest.

Two consequences worth knowing:

  • A dead login token does not discard live MCP tokens. They authenticate different things and expire independently, so a store holding MCP tokens is kept even when the Claude login in it has lapsed.
  • llmenv doctor appends the MCP token count to its credential line, e.g. OAuth credential cached at โ€ฆ (+3 MCP server tokens).

The claude.ai connectors are managed by the Claude desktop app rather than Claude Code, so they're outside llmenv's scope. llmenv doctor --gc additionally drops the macOS keychain item belonging to each cache folder it deletes, since that item would otherwise outlive the folder it was keyed to. Entries are matched by folder path, so your default ~/.claude login is never touched. This runs only under --gc, never on export.

marketplace: and plugin-collection:โ€‹

(added in v1.0.0)

marketplace:
- name: superpowers
source: "https://github.com/obra/superpowers.git" # git URL or local path

plugin-collection:
- name: dev
when: [me]
plugins:
- "superpowers:caveman"

A marketplace source is classified as git (cloned into <cache_dir>/marketplaces/<name>/, refreshed by plugin-sync) or a local path (used in place). Recognized git schemes: https://, http://, ssh://, git://, git+ssh://, plus scp-style git@host:owner/repo. Anything starting with /, ~, ./, or ../ is a path.

A plugin-collection fires by tag like a bundle; its plugins are <marketplace>:<plugin> references. See Plugins.

A marketplace name must match [A-Za-z0-9._-]+, must not be . or .., and must not start with -. The names claude-plugins-official, claude-code-plugins, claude-code-marketplace, anthropic-marketplace, and anthropic-plugins are reserved. They need a GitHub source in the anthropics org. A plugin-collection entry that names an unknown marketplace is a config error.

(added in v3.12.0) The plugins inside a marketplace manifest can come from other repositories, with ref and sha pins. See Plugin sources in a marketplace manifest.

host:โ€‹

A static table mapping host names to reachable addresses, consumed by memory:.

host:
fixed:
addr: "fixed.local"

addr must be a valid hostname or an IP literal. Any other value is a config error.

init:โ€‹

Settings pre-seeded into new materialized folders during llmenv init (#172). The interactive setup wizard lets you import keys from your global ~/.claude/settings.json; selected keys are stored here and survive every re-materialization.

init:
seeded_settings:
enabledPlugins:
superpowers@claude-plugins-official: true
autoMemoryEnabled: false

llmenv init writes this block automatically during the interactive import step; it is not normally hand-authored.

skills:โ€‹

First-class skill declarations at the top level, selected onto scopes by tag intersection โ€” the same model as mcp: and lsp:. Skills are supported by every adapter with a skills-directory concept; adapters without one silently skip them (#661).

skills:
- name: my-skill
when: [me]
path: "./path/to/skill/dir" # local path or marketplace-relative
FieldRequiredNotes
nameyesRegistration name; deduplicated first-bundle-wins
whennoActivation tags (empty = always active)
pathyesPath to skill directory โ€” absolute, ~/-relative, or bundle-content-relative

Skills declared here are merged with per-bundle skills from bundle.yaml; the union is what gets wired up for the active scope. Name collisions are resolved by declaration order (first wins).

output_styles:โ€‹

(added in v3.10.0)

Output styles change how Claude Code responds (role, tone, format) by editing the system prompt โ€” not what it knows, unlike CLAUDE.md/rules content. Declared at the top level or per-bundle, selected onto scopes by tag intersection โ€” same model as skills:/lsp:.

output_styles:
- name: concise
description: Terse, no preamble
content: |
Answer in as few words as possible. No explanations unless asked.
when: [me]
FieldRequiredNotes
nameyesRegistration name; deduplicated first-bundle-wins
descriptionyesOne-line description
contentyesMarkdown body appended to the system prompt (Claude Code) or the skill's body (fallback adapters)
whennoActivation tags (empty = always active)
keep_coding_instructionsnoKeep Claude Code's built-in coding instructions alongside this style. Default false โ€” see the warning below. No effect on the fallback path
force_for_pluginnoClaude Code plugin styles only โ€” auto-activate whenever the plugin is enabled. llmenv doctor flags it set outside a plugin bundle, since it has no effect there

With the default keep_coding_instructions: false, Claude Code's built-in coding instructions โ€” including its git-safety guidance (don't commit unless asked, don't force-push, don't touch git config) โ€” are replaced entirely by content, not merged with it. Set keep_coding_instructions: true to keep those guardrails active alongside the style.

Claude Code renders each tag-active entry to output-styles/<name>.md with the corresponding YAML frontmatter, and sets outputStyle in settings.json to the one non-force_for_plugin style, when exactly one is active. Zero or more than one leaves the selector untouched โ€” unlike memory/ codebase_memory (which resolve to a single MCP registration slot), holding multiple style files simultaneously is not a conflict; only the selector is single-valued.

Every other engine (Crush, opencode) has no native output-style concept, so the same name/description/content renders as a generated skill instead (skills/<name>/SKILL.md) โ€” automatic, no config-author-side fallback logic. keep_coding_instructions/force_for_plugin have no effect on this path. A style name that collides with a first-class skill, a reserved built-in skill name, or a skill projected from an installed plugin is rejected at materialize time, instead of silently overwriting or being shadowed by that skill.

Project markersโ€‹

Per-project configuration lives in a .llmenv.yaml file at the project root โ€” not in config.yaml. llmenv discovers it by walking the current directory upward to $HOME.

id: myapp # defaults to the folder basename
name: MyApp # defaults to the folder basename
description: "Customer API" # capped at 1024 bytes
tags: [myapp, rust] # joined into the active tag set
enable_bundles: [base] # force-enable bundles regardless of their tags
disable_bundles: [yaks] # force-disable bundles even if a scope's tag enables them

All fields are optional; an empty file is valid. disable_bundles always wins over any scope's tag-firing or enable_bundles for the named bundle, including this same marker's own enable_bundles if it lists the same name โ€” see Concepts โ†’ Precedence. Unknown fields are reported by llmenv doctor, which also flags a disable_bundles/ enable_bundles entry referencing an unknown bundle or the same bundle appearing in both lists. Malformed YAML degrades to defaults derived from the folder basename. See Concepts โ†’ Project markers for discovery rules.

Disabling a bundle withdraws everything it contributes, not just its permissions and instruction files. That includes any features.memory or host: entry declared in its bundle.yaml โ€” so if the ICM memory backend is declared only by a bundle you disable, memory recall/store and session logging are inactive in that project. Declare features.memory at the top level of config.yaml if it should survive a bundle being turned off.

(added in v3.8.0) llmenv names disable_bundles as the cause rather than leaving you to guess. Lifecycle hooks report no memory backend active for this scope: features.memory is supplied only by bundle(s) <name>, which this project turns off via disable_bundles, and llmenv doctor --all warns about the same thing โ€” previously both were silent, so memory worked in ~/ and stopped the moment you cd'd into the project with a green doctor. If a top-level features.memory entry's server_host was declared in the disabled bundle's host: table, the resulting error names the bundle too instead of only the missing host key.

Each tag (and each enable_bundles/disable_bundles entry) must be alphanumeric plus -/_ and no longer than 64 bytes; entries outside that charset or length, and any beyond the first 64 from a given source, are dropped with a warning: line naming the tag rather than breaking the session (the warning became visible by default in v3.11.0; before that it went to a log level nothing displayed). The same rule applies to $LLMENV_EXTRA_TAGS below and to tags declared on config.yaml's network/host/user/content scopes.

Activating tags without a committed markerโ€‹

$LLMENV_EXTRA_TAGS (comma-separated) unions additional tags into the active set without requiring a .llmenv.yaml at all โ€” useful for a client repo you can't add config files to, a throwaway clone, or a personal preference you don't want to share with collaborators via a checked-in file:

export LLMENV_EXTRA_TAGS="rust,personal"

These tags are additive on top of whatever .llmenv.yaml already contributes (or on top of nothing, if there's no marker file present). See docs/env-vars.md for the full variable reference.

YAML gotchasโ€‹

YAML coerces unquoted scalars. Quote values that could be misread:

  • Addresses like "0.0.0.0:7878" or anything with colon + space โ€” otherwise YAML parses a nested mapping.
  • Boolean-looking strings (yes, no, on, off, true, false).
  • MAC addresses, SSIDs, and URLs.

Validationโ€‹

llmenv status # active scopes/tags + parse status
llmenv doctor # full wiring validation (orphan scopes/tags/bundles/plugins)

Both report parsing errors and missing required fields. doctor additionally flags orphans โ€” scopes whose tags no contributor consumes, contributors whose tags no scope emits, a memory server_host missing from host:, and unknown fields in project markers.