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:
$LLMENV_CONFIG_DIR, if set.- The platform config dir (
~/.config/llmenvon Linux/macOS).
Top-level blocksโ
| Block | Shape | Purpose |
|---|---|---|
cache: | map | Local materialization cache + sync behavior |
scope: | map of lists | Network / host / user scope definitions |
capabilities: | map | Engine-neutral permissions, hooks, plugins (+ native_* overrides) |
native: | map (per engine) | Opaque per-engine passthrough for keys no feature models |
bundle: | list | Environment-variable + file bundles |
mcp: | list | MCP server declarations |
lsp: | list | LSP server declarations (Crush + Claude Code; no-op on engines without an LSP surface) |
features: | map | Feature 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: | map | Session-activity logging: local JSONL file and/or ICM transcript |
statusline: | map | Widget layout, formatting, and colour config for llmenv statusline |
state: | map | Durable per-tool state relocation (survives cache folder churn) |
marketplace: | list | Plugin marketplaces (git URL or local path) |
plugin-collection: | list | Named bags of plugins, selected by tag |
skills: | list | First-class skill declarations, selected by tag (same model as lsp:) |
output_styles: | list | Claude Code output styles, selected by tag (added in v3.10.0; see output_styles:) |
host: | map | Host name โ reachable address (used by features.memory:) |
init: | map | Settings seeded into new materialized folders by llmenv init |
disabled_engines | list | Engine 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:
| Mode | Folder layout | When 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
matchfields: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
matchfield:hostname(compared case-insensitively). - User
matchfield:user(exact match against$USER). - Content (added in v3.3.0)
matchfields:glob(matched against paths relative to the working directory) anddepth(optional; caps how many directories deep the search descends โ omit for an unbounded search). Unlikenetwork/host/user, which check environment facts (network gateway, hostname,$USER),contentscopes activate based on what files exist in the working tree โ e.g. gating a bundle's hooks to only fire when*.rsfiles 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.projectblock. Project scopes come from.llmenv.yamlmarkers, notconfig.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. Either1c:0b:8b:e4:5f:94or the form that macOSarpprints,1c:b:8b:e4:5f:94, matches (fixed in v3.12.0).cidrโ an IPv4 or IPv6 block such as192.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/16for 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_modeandpermissions.presetare scalars (resolved by precedence);allow/ask/denyare lists (concatenated + deduped).- A permission rule has a
toolplus either a globpatternor a list ofpaths. permissions.preset(added in v3.8.0) expands, at merge time, into a curated set ofallowrules โsafe-readonlyis 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-onlygitsubcommands (status,diff,log,show,blame) andls, so agents stop hitting a permission prompt for tools the rules themselves told them to prefer.git status/diff/log/show/blameandlseach get both a bare form and a*-suffixed one, since the bare form is their dominant invocation.rg/ast-grep/shfmt(notshellcheck) each ship adenycompanion 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 checksdenybeforeallow, so those specific invocations still prompt.fdis deliberately not in the preset despiterg'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 globdenycan't actually close it โ see #1219. A rule the preset already covers doesn't duplicate one an explicitallow/denyentry also declares. Runllmenv doctorto 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, optionalmatcher, and ahandlerof typecommand(withcommand:) ormcp_tool(withtool:). Hook command paths declared in a bundle are bundle-relative and resolved at materialize time. pluginsare<marketplace>:<plugin>strings.envis a map of environment variables that llmenv exports for the session. A later contributor overrides an earlier one. A key that starts withLLMENV_, 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'sautoMemoryEnabledsetting. 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:
- The
--permission-modeflag or--dangerously-skip-permissionsflag on theclaudecommand. permissions.defaultModein the user's~/.claude/settings.json(rendered by llmenv here). Setting this in a project's.claude/settings.jsondoes not workโautoandbypassPermissionsrequire the user file.- Claude Code's built-in default:
autofor interactive terminal or VS Code sessions (Claude Code 2.1.283+).default(Manual) forclaude -por the Agent SDK.- If
disableAutoModeis set to"disable"anywhere in the settings, defaults todefaultinstead ofauto.
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:
| Field | Values |
|---|---|
effort_level, features.slippage.effort_level, model_effort.<id>.effort_level | low, medium, high, xhigh |
model_effort.<id>.max_effort_level | low, 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:
effort_levelgoes to the top-leveleffortLevelkey. Opus 5, Fable 5.1, and earlier models read that key.- Opus 5.5 and later models ignore the top-level key in the user settings file, and llmenv's rendered
settings.jsonis that file. So llmenv also writeseffort_leveltomodelSettings.<id>.effortLevelfor each of those models. - Each
model_effortentry goes tomodelSettings.<id>aseffortLevelandmaxEffortLevel. 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[].idis the stable identifier, used as the map key on render and as thedefault_models[].providertarget.whenintersects with active scope tags โ same selection mechanism asmcp/lsp/skills.api_key/headersare passthrough strings โ llmenv writes exactly what you put here into the materialized config verbatim, with no resolution or interpretation of its own. Use a$VARor!commandreference, not a literal key, so the credential isn't committed to your config repo or synced byllmenv sync; the target engine resolves the reference at its own runtime.disabled: trueexcludes the provider from the resolved set for all engines.- Per-model request options (e.g. opencode's
reasoningEffort, or a local server'senable_thinking) have no dedicatedmodel_providers[].models[]field, but reach opencode throughnative_model_providers.opencode(#1007) โ its renderedprovider.<id>.modelsis 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 renderedproviders.<id>.modelsis a JSON array, sonative_model_providers.crushcan 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_modelsis a role-keyed map (large,small, or any role name the target engine recognizes) pointing at a{ provider, model }pair.providermay reference amodel_providers[].iddeclared alongside it, or an engine builtin (e.g. Crush's built-inanthropic) that llmenv doesn't validate against. opencode only has two default-model slots (modelandsmall_model), so only thelargeandsmallroles 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-rolemodelsblock for Crush's own per-role extras thatdefault_modelshas no field for (reasoning_effort,think,max_tokens) โ opencode has no equivalent slot, since itsmodel/small_modelare bare"provider/model"strings with no room for extra fields. Bothmodel_providersanddefault_modelsmay also be declared inside a bundle'sbundle.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"
| Field | Required | Notes |
|---|---|---|
name | yes | Registration name in the agent's MCP config |
when | no | Activation tags |
type | no | stdio (default), http, or sse |
command | for stdio | Executable to launch |
args | no | Arguments for command |
env | no | Environment for the launched process |
url | for http/sse | Remote endpoint |
headers | no | HTTP request headers for http/sse servers, such as an auth token (added in v3.0.0) |
timeout | no | Request timeout in seconds; unset uses the engine default (added in v3.0.0) |
disabled_tools | no | Tool names the engine hides from the model for this server (added in v3.0.0) |
disabled | no | true excludes the server from every engine (added in v3.0.0) |
always_load | no | Claude 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
| Field | Required | Notes |
|---|---|---|
name | yes | Registration name in the agent's LSP config |
when | no | Activation tags |
command | yes | Executable to launch |
args | no | Arguments for command |
env | no | Environment for the launched process |
disabled | no | Excludes the server from every engine when true |
filetypes | no | Crush only: language identifiers the server handles (e.g. ["rust"]) |
root_markers | no | Crush only: filenames/patterns that anchor the workspace root |
extension_to_language | no | Claude Code only (required there): file extension โ language id, e.g. {".rs": "rust"} |
init_options | no | Opaque data forwarded verbatim as the LSP initialize handshake options |
timeout | no | Crush 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]
| Field | Required | Notes |
|---|---|---|
server_host | yes | Key into host: for the daemon host |
port | yes | Port the proxy listens on / clients connect to |
listen_host | no | IP address to listen on (127.0.0.1 for loopback, 0.0.0.0 for all interfaces); default 127.0.0.1 |
when | no | Activation tags |
default_topics | no | Documentation only; preserved across round-trips |
mcp_permissions | no | Per-tier permission override for the ICM MCP's tools โ see mcp_permissions below |
wakeup_max_tokens | no | Token budget for the SessionStart wake-up call, 20-4000 (added in v3.8.0) |
adaptive_recall | no | Per-session adaptive recall, true or false, default true (added in v3.12.0) |
always_load | no | Claude 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_type | no | Memory type, episodic, semantic, or procedural, that llmenv tags on the memories it stores (added in v3.0.0) |
default_importance | no | Importance, low, medium, high, or critical, that llmenv tags on the memories it stores (added in v3.0.0) |
type_importance | no | Map from memory type to importance, for a per-type default (added in v3.0.0) |
auto_prune | no | true runs llmenv memory prune during llmenv materialize; default false (added in v3.3.0) |
retention | no | Per-type retention durations for llmenv memory prune. While set, prune refuses to run โ see llmenv memory prune (changed in v3.12.0) |
consolidation | no | Post-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
| Field | Default | Notes |
|---|---|---|
enabled | false | Turns consolidation on |
backend | claude-cli | claude-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_session | 10 | Maximum 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.
| Field | Required | Notes |
|---|---|---|
when | yes | Activation tags; an entry with none is rejected at validate time |
index_path | no | Override 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_permissions | no | (added in v3.10.0) Per-tier permission override for codebase-memory-mcp's tools โ see mcp_permissions below |
mem_budget_mb | no | (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_roots | no | (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 toindex_pathwhen you configure one; otherwise left unset, socodebase-memory-mcpfalls 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 ownindex.logabove. That directory still exists and is still where the log goes, but it's no longer handed tocodebase-memory-mcpas its cache: withindex_pathunset, the actual index now lives wherevercodebase-memory-mcpputs its own default (~/.cache/codebase-memory-mcp/), a different location than the log.CBM_MEM_BUDGET_MBโ set tomem_budget_mbwhen 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 stopindex_repositoryfrom being steered outside the intended project; per explicit user direction, llmenv no longer imposes that restriction โcodebase-memory-mcpapplies its own default scoping instead. This is a real security-posture change, not just cleanup:codebase-memory-mcpwill 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 withcodebase-memory-mcp's ownallow-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_snippettake a free-formprojectparameter 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-projectindex_pathif 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 anameoverride 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 theasktier the waydelete_projectitself is. (since v3.11.0 aPreToolUsehook denies thenameoverride โ see Theindex_repositoryname 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
| Field | Required | Notes |
|---|---|---|
backend | yes | Usage-data backend; currently only umans |
when | no | Activation tags (an entry with none never activates) |
cache_ttl | no | Seconds a polled usage snapshot is cached; default 30 |
max_wait | no | Hard cap in seconds on any single delay; default 300 |
soft_threshold | no | Remaining-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"
| Field | Required | Notes |
|---|---|---|
track | no | "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
thresholdtimes 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_trackeris on and the task tracker's "you still have a task in progress" reminder fires identicallythresholdtimes in a row, this appends a pointer tollmenv task wait <slug> "<reason>"โ the actual way to silence the reminder while genuinely blocked โ instead of just repeating the same "keep working" imperative forever. Pastthreshold * 3repeats (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 thetask waitpointer 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
| Field | Required | Notes |
|---|---|---|
enabled | no | Default true. |
threshold | no | Consecutive 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
| Field | Required | Notes |
|---|---|---|
enabled | no | Default 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
| Field | Required | Notes |
|---|---|---|
enabled | no | Default false. Set to true to activate the built-in plugin. |
mcp_permissions | no | Per-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:
| Tier | Action |
|---|---|
read_only | allow |
mutation | allow |
destructive | ask |
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)
| Field | Required | Notes |
|---|---|---|
enabled | no | Default false (opt-in). |
mode | no | "warn" (default) โ advisory only, or "deny" โ blocks the re-read. |
ttl_seconds | no | Seconds 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
| Field | Required | Notes |
|---|---|---|
enabled | no | Default 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_tools | no | (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. |
nudges | no | (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_commit | no | (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_skills | no | (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_after | no | (added in v3.12.0) Mutating tool calls with no task before the first nudge. Default 8. Must be 1 or more. |
nudge_every | no | (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
| Field | Required | Notes |
|---|---|---|
enabled | no | Default false (opt-in master switch). |
effort_level | no | One of low, medium, high, xhigh (validated since v3.12.0). Used when capabilities.effort_level is unset; see effort_level. |
compact_survival | no | Default true. Merges a short rules fragment into the generated CLAUDE.md reminding the agent to re-read its rules after context compaction. |
diagnose_command | no | Default true. Materializes a /diagnose skill: a structured symptoms โ evidence โ hypotheses โ test โ act checklist. |
rule_reinjection | no | Default 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_edit | no | Default 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_critique | no | Default true (added in v3.11.0). Appends a short checklist at Stop (tests run, anomalies explained, scope finished). Advisory โ it never blocks. |
metrics | no | Default 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_act | no | Default 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_act | no | Default 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
| Field | Required | Notes |
|---|---|---|
enabled | no | Default false. --container/--no-container on llmenv launch override this for one invocation. |
runtime | no | Default auto (probes PATH for podman, then docker). docker/podman force one engine without probing. |
image | no | Default null โ llmenv's published default sandbox image. Any other value overrides it. |
forward_ssh_agent | no | Default 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.
| Field | Required | Notes |
|---|---|---|
transcript | no | ICM transcript sink, recorded via the ICM MCP. Enabled by default; omit the block entirely and you get this sink at info |
file | no | Mirror the same event stream to a local JSONL file; disabled by default |
max_content_bytes | no | Cap 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-field | Required | Applies to | Notes |
|---|---|---|---|
enabled | no | both | Turn the sink on or off |
level | no | both | Minimum 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 |
path | no | file | Override the file sink's path; default <state_dir>/session-log.jsonl |
retention_days | no | transcript | Stale 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, andverbose: truebecamelevel: debugon 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 inpath: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_startat session start,scopecarrying the active tags/bundles/project,lifecycle_endat session end โ and also every prompt submission, notification, stop, subagent stop, and pre-compact event, tagged with its role.infois 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) andtool_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: debugcaptures 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 (thefilesink) and/or sent to ICM (thetranscriptsink) unredacted, capped only bymax_content_bytes(default 16 KiB, not a sensitivity filter). Treat asession-log.jsonlrecorded atdebugthe 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: "โ"
| Field | Required | Notes |
|---|---|---|
rows | no | One 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_set | no | auto, nerd, simple, or none โ see icon_set below. Default auto |
style.color | no | Master 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 |
widgets | no | Map 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 |
icons | no | Named icon overrides, merged over the resolved icon_set defaults (a name set here always wins) |
Each entry under widgets: accepts:
| Sub-field | Notes |
|---|---|
format | Custom 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_len | Max character length; longer output is truncated with โฆ (U+2026), UTF-8-safe. Default: no limit |
style | ANSI 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 |
display | Named 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 |
width | Bar cell width for context/cache_usage/usage_5h/usage_7d (default 10). Ignored by other widgets |
thresholds | Two 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.
| Widget | Format? | Default output | Example | format placeholders |
|---|---|---|---|---|
model | yes | {short_name} {version} | Opus 4.8 | short_name, version, full_name |
folder | yes | ๐ + basename of the working directory | ๐ llmenv | basename, path |
branch | yes | ๐ฟ + git branch name | ๐ฟ release/3.x | name |
pr | yes | #<number> (or the URL in display: url) | #834 | number, url, review_state |
context | yes | used-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 |
tokens | yes | total context tokens, k/m-suffixed | 10k | total, input, cache_read, cache_create |
budget | yes | <used>/<max>, k/m-suffixed | 35k/200k | used, max |
duration | yes | โฑ + elapsed (h+m past an hour, else m+s, else s) | โฑ 3h 42m | h, m, s, total_ms |
cache_usage | yes | โป + 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_5h | yes | Claude.ai 5-hour usage window | 5h 8% (+4.5) โก3% โก23m | pct, bar, reset, pace, delta |
usage_7d | yes | Claude.ai 7-day usage window | 7d 41% โก3d4h | pct, bar, reset, pace, delta |
peak | yes | peak / off-peak billing window (local clock) | โณ peak 3h03m | symbol, 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:
branchreads 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. Aworktree.branchin the stdin JSON (worktree sessions) takes precedence. Detached HEAD renders empty.modelstrips a trailing(โฆ)qualifier (e.g.Opus 4.8 (1M context)โOpus 4.8) and, when the engine sends no separateversion, derives it fromdisplay_name.- Numeric counts (
tokens,budget) usekat a thousand andmat a million, dropping a redundant trailing.0(1000000โ1m,200000โ200k,109200โ109.2k). usage_5h/usage_7drequire the Claude.ai subscriptionrate_limitsblock, 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 whenCLAUDE_CONFIG_DIRis 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_5hdefault[70, 90],usage_7d[60, 80]; override withthresholds).peakis 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).pris colored by{review_state}when the engine sends one:approvedgreen,changes_requestedred,pending/review_requiredyellow. When a PR URL is present and color is on,pr(and thebranchwidget) render as an OSC 8 terminal hyperlink to the PR โ the URL is validated (http/httpsonly, no control chars) before it's embedded.prself-resolves when the engine sends none โ Claude Code never sends aprfield on stdin, so llmenv runsgh pr viewfor the branchbranchresolves (git, from the working directory) and mapsgh'sreviewDecisiononto the samereview_statevalues above. The result is cached for 60 seconds (keyed by repo + branch, alongside theusage_5h/usage_7dstate under$CLAUDE_CONFIG_DIR/statusline-state/) so the statusline โ re-rendered on every prompt โ doesn't shell out on every render.prrenders empty, with no error output, wheneverghisn't installed, isn't authenticated, there's no remote, HEAD is detached, or there's no open PR for the branch. An engine-suppliedpralways takes precedence over the derived one. Thebranchwidget'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 theghlookups.- 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.
scopesre-reads$LLMENV_EXTRA_TAGSlive on every render and unions it onto the snapshot's tags (added in v4.0.0; fixes #1538).pluginsresolves entirely from top-level config (no merged-manifest input), so it recomputes live on every render against the same unioned tag setscopesuses (added in v4.0.0; fixes #1547).mcpsandthrottleneed the merged manifest's bundle-contributed data to re-resolve, which isn't available at render time without rebuilding it โ the workregeneratedoes. 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.
| Widget | Default format | Example | Placeholders |
|---|---|---|---|
scopes | {tags} | dev ยท rust | tags (tag list, joined with ยท; $LLMENV_EXTRA_TAGS is live, every other source is from the snapshot) |
plugins | ๐ {total} | ๐ 12 | total, errors (fully live-recomputed, not snapshot-sourced) |
mcps | MCP {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} | ๐ง 142 | memories, concepts |
cache | {prunable} | 15 MB | prunable (humanized), prunable_raw (bytes) |
config_stale | {stale_icon} stale | โ๏ธ stale | stale_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} | ๐ 8 | icon, entries |
tasks | โ {done}/{total} (summed across the current project's open sessions, #905); renders empty when no session is open for this project | โ 2/5 | done, 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 stringauto(default) โ there's no portable way to probe a terminal for a Nerd Font, soautokeys off theLLMENV_NERD_FONTenvironment variable: set it to1ortrue(case-insensitive) to get Nerd Font glyphs; unset (or any other value) falls back tosimple. 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:
#rrggbbhex
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
| Field | Required | Notes |
|---|---|---|
env | yes | Env var the tool honors (e.g. CONTEXT_MODE_DATA_DIR) |
subdir | yes | Single 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.
| State | Where it lives | How it's inherited |
|---|---|---|
/resume transcripts | projects/<escaped-cwd>/<session-uuid>.jsonl | The folder's projects/ is a symlink to $LLMENV_STATE_DIR/projects, so every folder shares one transcript store |
Prompt history (โ recall) | history.jsonl | Copied in from $LLMENV_STATE_DIR when the folder has none |
| MCP "needs auth" record | mcp-needs-auth-cache.json | Copied in when the folder has none, so Claude Code doesn't re-probe every OAuth MCP server |
| OAuth credential | macOS keychain, service name keyed by the config-dir path; .credentials.json elsewhere | Cached 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 doctorappends 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
| Field | Required | Notes |
|---|---|---|
name | yes | Registration name; deduplicated first-bundle-wins |
when | no | Activation tags (empty = always active) |
path | yes | Path 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]
| Field | Required | Notes |
|---|---|---|
name | yes | Registration name; deduplicated first-bundle-wins |
description | yes | One-line description |
content | yes | Markdown body appended to the system prompt (Claude Code) or the skill's body (fallback adapters) |
when | no | Activation tags (empty = always active) |
keep_coding_instructions | no | Keep Claude Code's built-in coding instructions alongside this style. Default false โ see the warning below. No effect on the fallback path |
force_for_plugin | no | Claude 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 withcolon + 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.