Skip to main content

Engines

llmenv emits agent-native configuration through pluggable adapters. The configuration you write is engine-neutral; each adapter translates it into one engine's native shape. Anything that can't be expressed neutrally drops through a per-engine escape hatch.

Four adapters ship today: Claude Code, Codex, Crush, and opencode. Each activates when its binary is on PATH; users who only have one of those binaries see no output from the other adapters. The design doc behind this model is docs/design/engine-capabilities.md (related: #34, #59).

The principle​

Don't model the container. Model the capabilities inside it.

The portable concepts — which tools are allowed, which paths are reachable, which hooks fire on which events, which plugins load — are engine-agnostic. Each adapter renders them into its native config. Everything non-portable goes through a per-engine native passthrough.

Two layers​

Every modeled feature has both of these:

  1. Generic capability — an engine-neutral declaration, translated per adapter. Lives under capabilities: (permissions, hooks, plugins) and under mcp: for servers.
  2. Per-engine native_<feature> override — a raw fragment in the engine's own language, emitted verbatim. Named as a top-level sibling under capabilities:: native_permissions, native_hooks, native_plugins, native_mcp, native_model_providers.

A feature with only layer 1 is considered incomplete — there is always some platform-specific need (a Claude-only permission grammar, a Codex-only hook event) that requires the override.

Engine keys are validated​

(added in v3.8.0)

Every native_<feature> map is keyed by an engine id, and each adapter reads only its own key. Two kinds of key are therefore never rendered:

  • An unknown engine id — a typo like native_mcp.opencde. Engine ids are matched exactly, so Claude_Code counts as unknown too: adapters look the key up verbatim.
  • A real engine whose adapter doesn't read that map. Each adapter declares which native_<feature> maps it consumes. native_model_providers.claude_code is dead because Claude Code is Anthropic-only with no provider block; native_hooks.opencode and native_plugins.opencode are dead because opencode renders hooks from the neutral capabilities.hooks through its shim and plugins from the resolved plugin list, never from a per-engine fragment.

The current matrix of which adapter reads which map:

Mapclaude_codecodexcrushopencode
native_permissionsyesnoyesyes
native_hooksyesyesyesno
native_pluginsyesnonono
native_mcpyesyesyesyes
native_model_providersnonoyesyes
nativeyesyesyesyes

llmenv export, llmenv regenerate, and llmenv doctor warn about both kinds, reading the merged config so a key contributed by a bundle.yaml is covered too. llmenv validate goes further and fails on an unknown engine id, since there is no legitimate reason to write one; a key naming a real engine that doesn't read the map stays a warning there, because sharing one config across engines makes it a deliberate no-op.

native_permissions is keyed by engine like every other map — an MCP server name is not a valid key. Per-MCP-server permissions are expressed as mcp__<server>__<tool> rule strings under an engine key, or through features.<name>.mcp_permissions.

capabilities:
permissions:
default_mode: acceptEdits
deny:
- { tool: Read, paths: ["./.env", "./.env.*"] }
native_permissions:
claude_code:
deny: ["WebFetch(domain:internal.example.com)"]

The neutral {tool, pattern} / {tool, paths} form covers the common case; the adapter generates Claude's Bash(...) / Read(...) string grammar — you never author it. native_permissions appends raw rule strings for the long tail.

For a given tool+pattern, deny always wins over ask/allow regardless of whether it came from the structured permissions: block or the engine's native_permissions override — a native allow can never silently unset a structured deny for the same rule.

The neutral tool vocabulary​

(table consolidated in v3.11.1; the mappings themselves predate it)

permissions[].tool names a tool in one neutral vocabulary — Claude Code's PascalCase tool names — and each adapter renders it in its own engine's grammar. Claude Code receives the name as written. opencode and crush have closed key sets, so their names are translated:

Neutral toolopencode keycrush toolNot one-to-one?
Bashbashbash
Readreadview
Editediteditcrush's edit tools also create missing files and parent directories, so allowing Edit on crush also allows file creation
Writeeditwriteopencode gates every file mutation through the single edit key, so a Write rule there also covers Edit
MultiEditeditmultieditopencode has no separate multi-edit key; same file-creation caveat as Edit on crush
Globglobglob
Grepgrepgrep
LSlistls
WebFetchwebfetchfetchcrush's more specialized agentic_fetch is not used
WebSearchwebsearch—crush's web_search is more specialized and isn't treated as a direct equivalent; the rule is dropped for crush
TodoWritetodowritetodos
Tasktask—dropped for crush
Skillskill—dropped for crush
NotebookEdit——neither engine has an equivalent; the rule takes effect on Claude Code only

A — means that engine has no analog llmenv will map to, so the rule is dropped for that engine and takes effect on the others. This is deliberate: guessing at a lowercase pass-through renders a key opencode's schema rejects (which makes it discard the whole config file) or a name crush's exact-match allowlist never matches. Use native_permissions.<engine> to target that engine's own tool directly.

A tool name that isn't in this table is not rejected — Claude Code gains tools llmenv has no reason to know about, and its adapter passes the name straight through, so such a rule still works there. export and regenerate report it once, because opencode and crush can only drop it. llmenv doctor additionally lists any tool in your config whose mapping onto an active engine falls in the "not one-to-one" column above.

The catch-all native: block​

Separately, the top-level native: block is a per-engine catch-all for keys that belong to no modeled feature (e.g. alwaysThinkingEnabled, outputStyle):

native:
claude_code:
alwaysThinkingEnabled: true

It is overlaid onto the engine's config last. hooks is a hard error here — it belongs in the matching native_<feature> sibling, so the security-rendered output is never silently clobbered.

native.claude_code.permissions​

(added in v4.0.0)

permissions used to be a hard error here too. It is now accepted and layered over the rendered permissions object, which makes the catch-all the escape hatch for Claude-Code-only permission keys llmenv doesn't model — additionalDirectories, disableBypassPermissionsMode, skipDangerousModePermissionPrompt, and whatever Claude Code ships next — without waiting on a neutral-schema field and an llmenv release:

native:
claude_code:
permissions:
additionalDirectories: ["/srv/shared"]
disableBypassPermissionsMode: "disable"

It is accepted because the merge is additive, not a replacement:

  • allow, ask, and deny are appended to what was rendered and deduped — never replaced. A fragment that omits deny, or sets it to null, leaves the rendered deny intact.
  • deny > ask > allow authority is re-applied afterwards, so a native allow of an already-denied rule is dropped rather than honoured.
  • Every other key overwrites — those carry no rendered security decision to weaken.
  • defaultMode is the exception and is rejected here. It is a modeled key (capabilities.permissions.default_mode), and setting it from the catch-all would override the rendered mode — including to bypassPermissions, which switches the permission system off entirely. Anything that can author a native: block, bundles included, would otherwise have a one-line escalation past every rendered ask and deny. Use the modeled field instead.

Rule strings here get the same Write → Edit normalization the native_permissions sibling applies, so a deny: ["Write(~/.ssh/**)"] isn't silently rendered as a rule that matches nothing.

The net effect is that a native fragment can tighten permissions or add keys llmenv doesn't model, but cannot loosen what the renderer produced. hooks keeps the hard error because it is an array of matcher groups, where "additive" has no unambiguous meaning; use native_hooks for those.

What the Claude Code adapter emits​

For each materialized environment, the adapter writes (all with 0600 permissions):

FileFrom
CLAUDE.mdthe merged AGENTS.md / rules content — omitted entirely when that resolves to nothing (added in v3.10.0; earlier versions wrote a 0-byte file)
settings.jsonpermissions, hooks, plugins (+ native_* overrides, + native: catch-all)
.claude.jsonresolved MCP servers upserted into mcpServers; foreign keys preserved (+ native_mcp)
skills/llmenv-lsp/.claude-plugin/plugin.jsonlsp: entries with extension_to_language set, as a synthetic skills-directory plugin (#556)

It also:

  • sets CLAUDE_CONFIG_DIR to the materialized directory so Claude Code uses it;
  • emits autoMemoryEnabled: false when the ICM memory server is present, so ICM and Claude's native auto-memory don't both write (a native override wins);
  • emits syncClaudeAiSkills: false and syncClaudeAiPlugins: false (added in v3.12.0) — since Claude Code 2.1.275, a terminal session signed in with a claude.ai account downloads the skills and plugins enabled on that account and loads them into every scope, going around llmenv's own scope rules and risking a name collision with an llmenv-managed skill or plugin. A native override (e.g. native.claude_code.syncClaudeAiSkills: true) re-enables sync; on the first render after upgrade, Claude Code moves any already-synced skills/plugins into skills/.trash/ and plugins/.trash/ inside the rendered config dir rather than deleting them. disableClaudeAiConnectors (claude.ai MCP connectors) is a separate setting and is not touched by this default — set native.claude_code.disableClaudeAiConnectors: true directly if you want connectors off too;
  • registers a SessionStart hook running llmenv hook-run session_start, which performs the drift check alongside memory wake-up (folded into one process in v3.11.0 — it was a separate llmenv check-stale hook before);
  • registers SessionEnd, and (added in v3.12.0) PostModelSwitch running llmenv hook-run post_model_switch — see Hook events;
  • renders alwaysLoad for an MCP server that sets always_load (added in v3.12.0; opencode and Crush have no such key and ignore the field).

Skipping CLAUDE.md in a subagent (Claude Code)​

(added in v3.12.0)

omitClaudeMd: true in an agent's frontmatter makes Claude Code start that subagent without the user, project and local CLAUDE.md files. Under llmenv the user CLAUDE.md is the rendered file with every bundle rule, so such a subagent runs without llmenv's rules. Use it only for narrow agents that get everything they need from the delegation prompt, such as report-only analyzers. Other engines ignore the field; opencode prints a warning and drops it.

Hook events (Claude Code)​

(added in v3.12.0)

The Claude Code adapter registers these hook-run events by itself. SessionStart, SessionEnd, and PostModelSwitch are always on. The memory events need the ICM memory server: UserPromptSubmit (turn_start), and, with adaptive_recall on, PostToolBatch, PostToolUseFailure, and SubagentStart.

PostModelSwitch fires when you change the model in a session (Claude Code 2.1.251 and later). It runs llmenv hook-run post_model_switch, which updates the model field of the session's agent-config document. At SessionStart, llmenv writes that document to the state dir (engine, model, effort, project, tags, and config hash). A resumed or compacted session starts with a one-line [llmenv session] summary of it. See Agent config. No other engine has this event, so only Claude Code sessions keep an agent-config document.

See Which hooks each engine supports for the other events.

Which hooks each engine supports​

(added in v3.12.0)

EventClaude CodeopencodeCrush
SessionStart, SessionEndyesyesno
UserPromptSubmityesyesno
PreToolUseyesyesyes
PostToolUseyesyesno
Stopyesyesno
Notification, SubagentStop, PreCompactyesnono
PostToolBatch, PostToolUseFailure, SubagentStartyesnono
PostModelSwitchyesnono

An event that an engine does not support is dropped with a warning on opencode and is a hard error on Crush. Crush runs only command-kind PreToolUse handlers.

Where capabilities are declared​

Capabilities can be declared at two levels with identical shape:

  • Globally under capabilities: in config.yaml.
  • Per bundle in an optional bundle.yaml inside the bundle's content directory — keeping a hook's script and its registration together so the bundle versions as a unit.

Contributors merge by value shape: scalars (like default_mode) resolve by scope precedence (network → host → user → project); lists (allow/ask/deny, hooks, plugins) concatenate and de-duplicate.

The Crush adapter​

(added in v3.0.0)

Crush is a second supported engine. It is PATH-gated: export, hook, and regenerate skip Crush silently if crush is not on PATH. When it is present, a separate crush/ subtree is materialized inside the llmenv cache directory.

Env vars​

VariablePoints toNotes
CRUSH_GLOBAL_CONFIG<cache>/crush/... (the directory containing crush.json)Crush joins crush.json onto this path itself — it must be a directory, not the file
CRUSH_GLOBAL_DATA<state_dir>/crushA dedicated subdir of the stable llmenv state dir; Crush needs no separate workaround

CRUSH_GLOBAL_CONFIG and CLAUDE_CONFIG_DIR use separate namespaces and can coexist in a single shell session without conflict.

Capability map​

FeatureCrush supportNotes
Permissions (allow)Coarse — tool-level onlyAn unscoped rule (no pattern/paths) is translated from llmenv's neutral tool vocabulary (Bash, Read, WebFetch — Claude Code's PascalCase names) to Crush's own tool identifiers (bash, view, fetch, ...) and rendered to allowed_tools (changed in v3.10.0 — previously rendered the neutral name verbatim, which never matched Crush's case-sensitive, differently-named tools; see #1321). A neutral tool with no Crush equivalent (Task, NotebookEdit, ...) is dropped and logged, same as a pattern/paths-scoped rule below — the neutral permission list is shared across engines, so a Claude-Code-only tool name is a normal config, not a Crush-specific error. Edit/MultiEdit also imply file creation under Crush: its edit/multiedit tools create missing files and parent directories on an empty old-content diff, unlike Claude Code's Edit, which requires an existing path — allowing Edit for Crush is closer to allowing Edit and Write combined. A pattern/paths-scoped rule is dropped entirely rather than widened to a whole-tool grant, since Crush's matcher can't express scoping at all (changed in v3.10.0; see #1306)
Permissions (ask/deny)No dedicated rendering, but cross-checked against allowCrush's PermissionsConfig has no denied_tools/default_mode concept, so ask/deny rules produce no key of their own. As of v3.10.0 they still suppress a same-tool entry in allowed_tools (Crush has nothing else to enforce a conflicting deny with) — before the allow-side name mapping landed, allow never matched a real Crush tool either, so this cross-check wasn't needed; see #1321
Hooks — PreToolUseSupportedcommand-kind handlers only
Hooks — other eventsHard errorCrush supports only PreToolUse; any other event in config is an error
Hooks — mcp_tool kindHard errorNo Crush equivalent; use command-kind instead
MCP serversSupportedIncludes headers, disabled_tools, timeout
LSP serversSupportedRendered to lsp.<name> entries
Skills (first-class)SupportedWritten via options.skills_paths
Skills (plugin-projected)SupportedPlugin skills/ subdirs are projected into Crush's skill paths
Output stylesFallback — generated skillCrush has no output-style concept; output_styles entries render as skills/<name>/SKILL.md instead, same name/description/content (added in v3.10.0, #1130)
Plugins / marketplaceHard errorCrush has no plugin or marketplace concept; non-skill plugin content (custom agents/, commands/) produces an actionable error naming the plugin
Custom agentsUnsupportedCrush hardcodes exactly two agent roles (coder/task); agents/*.md from plugins cannot be loaded
Model providers (model_providers/default_models)SupportedRendered to providers/models using catwalk's field names; api_type passes through as type verbatim

The native.crush escape hatch​

Keys that no modeled feature owns go under native.crush:

native:
crush:
model: claude-opus-5-5
provider: anthropic

capabilities.model_providers/default_models (see Configuration) is the first-class, engine-agnostic home for provider/model config — use this escape hatch only for Crush-specific fields it doesn't cover. The fragment is deep-merged verbatim into crush.json at highest precedence.

The native_permissions.crush, native_hooks.crush, native_mcp.crush, and native_model_providers.crush siblings work the same way for their respective domains.

native_model_providers.<engine>​

capabilities.model_providers models the fields every engine has in common, but Crush and opencode each accept provider and per-model keys it has no field for. native_model_providers.<engine> is the escape hatch: a raw fragment, keyed by provider id, deep-merged onto the rendered provider block — providers in crush.json, provider in opencode.json. The fragment is the higher-precedence layer, so a key it sets wins over the one rendered from model_providers; sibling keys are preserved.

It also works on its own — with no model_providers entries at all, the fragment alone renders the provider block, so a hand-written provider survives llmenv regenerate. The fragment must be a mapping; a scalar or list is rejected with an error rather than replacing the whole rendered block. Don't declare both a fragment and a disabled: true provider for the same id — the fragment renders regardless.

Per-model keys: opencode only

Crush renders models as a list, so a fragment's model entry is appended, not patched — use model_providers[].models for per-model config on Crush. opencode renders it as an object keyed by model id, so patching works.

capabilities:
model_providers:
- id: mtplx
base_url: http://localhost:8080/v1
api_type: openai
models:
- { id: gpt-oss }
native_model_providers:
opencode:
mtplx:
models:
gpt-oss:
reasoningEffort: high # no neutral equivalent — opencode-only

The opencode adapter​

(added in v3.6.1)

opencode is a third supported engine. Like Crush it is PATH-gated: export, hook, and regenerate skip opencode silently if opencode is not on PATH. When present, opencode's config is materialized into the llmenv cache directory and discovered via OPENCODE_CONFIG_DIR.

Unlike Crush, opencode is a full-featured target: it supports plugins, LSP, custom agents/commands, and six hook events, so it reaches near-parity with the Claude Code adapter.

Env vars​

VariablePoints toNotes
OPENCODE_CONFIG_DIR<cache> (the directory holding opencode.json)opencode reads opencode.json, AGENTS.md, and the plugin/ shim from here

What the opencode adapter emits​

OutputContents
opencode.json$schema (points at the opencode.schema.json sidecar below), instructions, mcp, lsp, permission, plugin — structured render, then native_*.opencode overlays deep-merged at the value level
opencode.schema.jsonJSON Schema (draft 2020-12) generated from the same typed structs that render opencode.json, so it always matches what llmenv actually writes. Root allows additionalProperties, so passthrough/native-overlay keys never fail IDE validation.
AGENTS.mdthe merged rules document opencode loads as project instructions — omitted entirely when that resolves to nothing (added in v3.10.0; earlier versions wrote a 0-byte file)
rules/*.mdrule files copied verbatim and listed in instructions
skills (SKILL.md)first-class and plugin-projected skills, in opencode's claude-compatible format
command/*.md, agent/*.mdplugin commands and agents translated (agents gain mode: subagent)
plugin/llmenv.jsa generated ES-module shim bridging opencode's JS plugin API to llmenv's hook-run subprocess

Hooks​

(per-turn hook parity added in v4.0.0, #1439)

llmenv wires its own hooks for opencode, the same baseline it gives Claude Code and Codex: the config-source context and managed-cache write guard/read-once dedup on PreToolUse, and the ICM memory/session-log lifecycle events on SessionStart/SessionEnd. These route through plugin/llmenv.js, the generated shim bridging opencode's JS plugin API to llmenv hook-run subprocess calls — there's no nested matcher-group config to write, since opencode dispatches by table entry in the shim itself.

The per-turn hooks are gated on configuration, exactly as they are for Claude Code and Codex:

  • turn_start on UserPromptSubmit — when a memory backend resolved for the scope (features.memory).
  • stop on Stop — when features.task_tracker is enabled, or when features.slippage has self_critique on.
  • user_prompt_submit on UserPromptSubmit — when features.slippage has rule_reinjection on and nothing else already claimed that event.
  • The session-log turn capture set — UserPromptSubmit, PreToolUse, PostToolUse, Stop — when any session_log sink is enabled, which it is by default (the transcript sink, at info). info already captures prompt submissions, not just tool calls — see What gets logged — so an opencode scope with no session_log: block still sends every prompt to ICM's transcript store unless the sink is explicitly disabled.

The session-log set is narrower than Claude Code's and Codex's: opencode has no Notification, SubagentStop, or PreCompact event (see the supported event list above), so none of the three are emitted — a shim table entry for an event opencode never dispatches would look wired and never fire. llmenv doctor reports which of these are wired for the active scope, alongside Claude Code and Codex.

Capability map​

Featureopencode supportNotes
Permissions (allow/ask/deny)Supported for the documented neutral tool vocabularyRendered as per-tool pattern → action maps; a bare tool emits a plain action string. ask is native (no fail-closed collapse). The neutral tool name is mapped to opencode's own permission key, source-verified against opencode's permission.ts schema (bash, read, glob, grep, webfetch, websearch, todowrite, task, skill are a straight lowercase; Write/MultiEdit both map to edit, LS maps to list — opencode has no separate write/multiedit/ls key). A neutral tool with no confirmed opencode equivalent is dropped rather than guessing at a key (fixed in v3.10.0, #1326), with a warning: line naming the tool (fixed in v3.11.0, #1345 — before that it went to a log level nothing displayed, so the rule vanished silently). This mapping applies only to capabilities.permissions; native_permissions.opencode strings are opencode's own vocabulary already (lsp, question, doom_loop, external_directory, a bare * deny-all, ...) and are lowercased verbatim, never mapped. Because Write and MultiEdit collapse onto the same edit key as Edit, rules for any of the three now interact with each other under one shared key — allowing Write and denying Edit (or vice versa) resolves against the combined edit pattern map, not two independent tools. Two rule shapes opencode cannot represent are rejected at regeneration time rather than rendered (fixed in v3.11.0, #1328) — see Permission rules opencode cannot represent
Hooks — SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, StopSupportedBridged through the generated plugin/llmenv.js shim
Hooks — other eventsWarned, skippedUnsupported events are dropped with an actionable warning rather than a hard error
Hooks — mcp_tool kindWarned, skippedNo opencode equivalent; use a command-kind handler
MCP serversSupportedLocal (command, ${HOME}-expanded) and remote (http/sse) transports
LSP serversSupportedRendered to lsp.<name> entries, with initialization_options
Skills (first-class + plugin-projected)SupportedNative SKILL.md format
Output stylesFallback — generated skillopencode has no output-style concept; output_styles entries render as skills/<name>/SKILL.md instead, same name/description/content (added in v3.10.0, #1130)
Plugins / marketplaceSupportedPlugin commands, agents, MCP, skills, and hooks are translated
Custom agentsSupportedPlugin agent/*.md are emitted with mode: subagent
Model providers (model_providers/default_models)SupportedRendered to provider.<id> / model / small_model; api_type maps to the AI SDK npm package (e.g. openai → @ai-sdk/openai-compatible). default_models only has large/small slots — other role names are a no-op

Permission rules opencode cannot represent​

(added in v3.11.0)

Two permission shapes have no faithful rendering in opencode.json. Both used to be emitted anyway and then silently misbehave, so llmenv regenerate now fails with an error naming the offending rules instead.

Scoped rules on an action-only key. opencode types todowrite, question, webfetch, websearch, and doom_loop as a bare "allow"/"ask"/"deny" string — unlike bash, read, edit, and the rest, they take no pattern → action map. opencode discards the entire config file when any single key fails to decode, and reports nothing, so one scoped rule here used to void every MCP server, LSP entry, and permission rule in the file:

capabilities:
permissions:
allow:
- { tool: WebFetch, pattern: "https://example.com/*" } # rejected
- { tool: WebFetch } # fine — covers the whole tool

Drop the pattern/paths so the rule covers the tool as a whole.

Two overlapping patterns where the later-sorting one isn't the narrower. opencode applies the last matching rule in config key order, and llmenv emits each tool's pattern map sorted by pattern. So for any two patterns that can match the same input, whichever sorts last governs every input they share. That's only what you meant if the last one is the more specific of the two:

capabilities:
permissions:
allow:
- { tool: Bash, pattern: "git *" } # sorts after "* --force*"…
deny:
- { tool: Bash, pattern: "* --force*" } # …so this never applied to "git push --force"

Writing a deny as a leading-* pattern to mean "anywhere in the command" is the case that bites: * sorts before letters, so the deny lands first and the allow wins on everything they share. Rewrite the later pattern so it only covers inputs the earlier one doesn't, or give the two the same action.

The common shape — a wildcard baseline plus a narrower override — is unaffected, because a pattern that fully contains another is exempt:

capabilities:
permissions:
allow:
- { tool: Bash } # renders as "*"
deny:
- { tool: Bash, pattern: "git push*" } # narrower, sorts last, still wins

This comparison spans permission keys, not just patterns within one key (added in v3.11.0, #1344). opencode flattens every key into one ordered rule list and wildcard-matches the key against the tool name as well, so a native rule keyed * applies to every tool and is checked against the concrete keys that sort after it:

capabilities:
native_permissions:
opencode:
deny: ["*(git push --force*)"] # key "*" sorts before "bash"…
permissions:
allow:
- { tool: Bash } # …so this allow won for "git push --force"

A bare native * deny-all baseline is still fine alongside per-tool rules — it's broader in both key and pattern, so the narrower per-tool rule winning is what you asked for:

capabilities:
native_permissions:
opencode:
deny: ["*"] # deny everything by default
permissions:
allow:
- { tool: Bash } # …except bash

The native.opencode escape hatch​

Keys that no modeled feature owns go under native.opencode, deep-merged into opencode.json at highest precedence:

native:
opencode:
theme: opencode

The modeled keys instructions, mcp, lsp, permission, provider, model, and small_model are rejected in the top-level native.opencode block — overlaying them last would clobber the security-rendered output. Route them through the sibling that merges in the safe direction instead: native_permissions.opencode, native_hooks.opencode, native_mcp.opencode, or native_model_providers.opencode for provider. model and small_model are plain provider_id/model_id strings with no engine-specific extras — use capabilities.default_models for those.

The Codex adapter​

(added in v4.0.0)

Codex is a supported engine, PATH-gated the same way as Crush and opencode: export, hook, and regenerate skip it silently when codex is not on PATH.

This is the first slice of Codex parity (#233) — MCP servers and the merged AGENTS.md. What isn't wired yet is listed below, with its tracking issue, so nothing here is a silent gap.

Env vars​

VariablePoints toNotes
CODEX_HOME<cache>/codex/... (the directory containing config.toml)Codex's analogue of CLAUDE_CONFIG_DIR; it must be the directory, not the file

What the Codex adapter emits​

config.toml, in Codex's own TOML config format:

  • mcp_servers — one table per resolved MCP server. A stdio server renders command/args/env; a streamable-HTTP server renders url (plus http_headers). There is deliberately no type key: Codex reads the transport from which key is present, and has no type field at all. (It would be ignored rather than rejected — Codex tolerates unknown keys — but writing a key the engine never reads is how a config drifts out of sync with reality.) A per-server timeout maps to tool_timeout_sec, since llmenv's timeout is a request timeout and Codex's startup_timeout_sec covers initialization instead.
  • model_instructions_file — an absolute path to the merged AGENTS.md, which is written alongside config.toml. Codex finds a project's AGENTS.md on its own; this pointer is for llmenv's merged copy, which lives in the cache directory rather than a project root.

SSE MCP servers are skipped​

Codex speaks stdio and streamable HTTP. It has no SSE transport at all, so an MCP server declared with transport: sse is skipped for Codex with a warning rather than rendered as a url — which Codex would read as streamable HTTP and then fail to talk to. Other engines still receive the server.

Hooks​

Codex takes the same nested matcher-group shape as Claude Code, under hooks.events.<Event>, and its event names match — so llmenv's engine-neutral hooks map across without a translation layer:

[[hooks.events.PreToolUse]]
matcher = "Bash"

[[hooks.events.PreToolUse.hooks]]
type = "command"
command = "…"

Two things are skipped with a warning rather than rendered:

  • An event Codex doesn't have. Notification is the live case — Claude Code has it, Codex doesn't. Codex ignores unknown keys, so emitting it anyway would leave a hook that looks wired and never fires.
  • mcp_tool handlers. Codex hooks run commands only.

llmenv also wires its own hooks for Codex, the same set it gives Claude Code (added in v4.0.0): the config-source context at SessionStart, the managed-cache write guard and read-once dedup on PreToolUse, the ICM memory and session-log lifecycle events on SessionStart/SessionEnd, and the throttle hooks when a throttle is configured.

The per-turn hooks are gated on configuration, exactly as they are for Claude Code (added in v4.0.0):

  • turn_start on UserPromptSubmit — when a memory backend resolved for the scope (features.memory). It runs on every prompt, so it stays off for a scope with no memory configured.
  • stop on Stop — when features.task_tracker is enabled, or when features.slippage has self_critique on.
  • user_prompt_submit on UserPromptSubmit — when features.slippage has rule_reinjection on and nothing else already claimed that event.
  • The session-log turn capture set — UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, PreCompact — when any session_log sink is enabled, which it is by default (the transcript sink, at info). info already captures prompt submissions, not just tool calls — see What gets logged — so a Codex scope with no session_log: block still sends every prompt to ICM's transcript store unless the sink is explicitly disabled.

The session-log set is Claude Code's minus Notification, for the reason above: Codex has no such event, so emitting it would leave a hook that looks wired and never fires. llmenv doctor reports which of these are wired for the active scope.

Those point at llmenv hook-run --engine codex, which works because Codex reads the same hook output shape Claude Code does — hookSpecificOutput carrying hookEventName and additionalContext — so injected context reaches the model without a translation layer.

Capability map​

CapabilityStatus
MCP serversrendered
Merged AGENTS.mdrendered, via model_instructions_file
Permissionsfilesystem access only (added in v4.0.0, #1102) — see Permissions
Lifecycle hooksrendered, including llmenv's own baseline hooks
Seeded settingsapplied to config.toml (added in v4.0.0, #1107) — see Seeded settings
Install-method seedn/a — Codex self-detects its own install method in-process (#1107)
Statuslinen/a — no external-command hook exists (added in v4.0.0, #1104) — see Statusline
Session/history/auth inheritanceinherited across hash changes (added in v4.0.0, #1105) — see Durable state inheritance
SQLite state DBsgoals/memories/queue inherited (added in v4.0.0, #1420) — see Durable state inheritance
Pluginsn/a — verified-absent from Codex's own source, no analogue of installed_plugins.json (added in v4.0.0, #1106)
LSPn/a — verified-absent from Codex's own source, no [lsp]/Lsp config surface (added in v4.0.0, #1106)
Skillsfirst-class + built-in llmenv skill, registered via [[skills.config]] (added in v4.0.0, #1106) — see Skills
Rules beyond merged AGENTS.mdfolded into AGENTS.md (added in v4.0.0, #1103) — see Rules
doctor diagnosticspermission profile status, SSE MCP servers, config.toml validity (added in v4.0.0, #1100) — see doctor

Seeded settings​

(added in v4.0.0)

init.seeded_settings (see init: for Claude Code's version of this feature) is merged into config.toml the same way: once a key is present — llmenv's own render, a prior seed, or a value Codex itself wrote — it is left alone. Seeding never touches a modeled key (mcp_servers, model_instructions_file, hooks, permissions, default_permissions, skills); those are llmenv's own render surface.

A security-sensitive key — approval_policy, sandbox_mode, sandbox_workspace_write, trusted_projects, shell_environment_policy — is refused with a warning rather than seeded, even though none of them are modeled keys. Permissions only render as far as filesystem access (#1102) — approval_policy/sandbox_mode remain unmodeled — so a seeded value there could silently run Codex less restrictively than the posture capabilities.permissions establishes on every other engine.

Codex needs no install-method seed the way Claude Code does: it detects its own install method (brew, npm, standalone, …) in-process from its own executable path, so there is no config key for llmenv to pre-seed.

Statusline​

(added in v4.0.0)

Claude Code's statusLine runs an external command and displays whatever it prints, which is what lets llmenv seed llmenv statusline there. Codex's tui.status_line is structurally different: a fixed, ordered list of built-in item identifiers (model-with-reasoning, git-branch, context-remaining, five-hour-limit, …) rendered natively by Codex's own TUI. There is no "run a command and show its output" surface, so llmenv statusline has nothing to attach to on Codex — this is a structural gap, not missing work.

Durable state inheritance​

(added in v4.0.0)

CODEX_HOME is llmenv's hashed cache dir, so anything Codex persists under it — session transcripts, prompt history, the cached login — would otherwise be lost on every config edit or version bump, the same problem Claude Code's /resume transcripts have. llmenv relocates the same way:

  • sessions/ and archived_sessions/ — Codex's transcript stores, the direct analogue of Claude Code's projects/. Relocated to the durable state dir and symlinked back in, so /resume history survives a hash change instead of a copy-per-hash.
  • history.jsonl — Codex's prompt-recall file, copied in when a folder has none and never overwritten once one exists.
  • auth.json — Codex's combined identity + OAuth token file. Copied in when a folder has none, same as history.jsonl, but capture uses a newest-mtime-wins rule instead of "only when the store has none": a re-login or token rotation replaces the store's copy, rather than pinning the first-ever captured credential forever and serving a stale or revoked token to every new folder indefinitely. Because Codex writes auth.json by truncating and rewriting it in place rather than atomically, capture reads it through a single file handle and only commits the read if the file's mtime was unchanged before and after, plus a JSON-validity check as a backstop on filesystems whose timestamp resolution is too coarse to catch a write in progress; a read that fails either check is discarded and retried on the next export instead of risking a torn credential in the durable store (added in v4.0.0, #1451).

Every copied file (history.jsonl, mcp-needs-auth-cache.json, auth.json) has its permissions forced to owner-only (0o600) regardless of the source's mode — a plain copy otherwise propagates whatever mode the source had, which would carry a looser umask into the durable store or a fresh folder alike.

Codex also writes six SQLite databases directly into $CODEX_HOME. Three are symlinked (base file plus -wal/-shm sidecars) into the durable state dir (added in v4.0.0, #1420). Unlike the files above, these are usually never copied at all: on a first-ever run the symlink is created before the file exists on either side, so Codex's own create_if_missing writes it straight into the durable state dir — inside the state dir's 0o700 permissions, but under whatever umask Codex itself uses, not the 0o600 forced on the copied files. A pre-existing real file from before this link existed is folded in once (copied, then owner-only) the same way the files above are. That fold-or-keep decision is made once from the base .sqlite file and applied to every sidecar together — a base file and its -wal are never folded from two different points in time (added in v4.0.0, #1449).

If any member of a DB's family (base file, -wal, or -shm) still looks unmigrated and the base name's -shm sidecar exists — evidence Codex may currently have the DB open in WAL mode — the fold is skipped for that export rather than risked: folding a live DB could copy a torn snapshot, or leave Codex's open file descriptor writing to the file's old, now-orphaned inode after the symlink swap. llmenv warns and retries the fold on the next export instead (added in v4.0.0, #1448). The check covers the whole family, not just the base file, so a sidecar left real by a partial prior fold failure — the base already symlinked, a -wal/-shm sidecar not — still gets the same protection (added in v4.0.0, #1450).

  • goals_1.sqlite — per-thread objectives/status/token budgets.
  • memories_1.sqlite — generated memory content and its extraction/ consolidation job state.
  • queue_1.sqlite — the durable user-message queue.

Each holds data with no other durable source, so losing the file on a hash change would silently reset it. The other three are deliberately left in place: state_5.sqlite and thread_history_1.sqlite are rebuildable indexes Codex reprojects from sessions/ on its own (a startup backfill and a lazy byte-offset projection, respectively), and logs_2.sqlite is a 10-day-retention diagnostic log, not data worth preserving across a hash change.

Permissions​

(added in v4.0.0)

Codex models permissions as named [permissions.<name>] profiles, selected by setting default_permissions to the profile's name — a profile that is never selected is dead config, since nothing else applies it. A profile bundles filesystem access (per-path read/write/deny) and network access together; only filesystem is rendered here.

capabilities.permissions rules map onto a [permissions.llmenv] profile, filesystem only:

  • Read → read, Edit/Write/MultiEdit → write, each applied to the rule's paths.
  • A deny rule wins over an allow rule at the same path, and write wins over read — Codex's own stated precedence (deny beats write beats read), so a path covered by more than one rule resolves the same way Codex itself would.
  • default_permissions = "llmenv" is set alongside the profile, so it's actually applied rather than merely defined.

This is all-or-nothing per config. Codex's permission profiles have no per-command allowlist (a Bash rule has nothing to map to) and no per-rule ask posture (only the global approval_policy/sandbox_mode — ask-tier rules are unconditionally unmappable regardless of tool). Rendering the mappable Read/Edit/Write/MultiEdit subset while silently dropping a Bash/WebFetch/ask rule would produce a profile that looks more complete than it is — a config carrying an unrendered deny should never look enforced. So a single unmappable rule anywhere in allow/ask/deny refuses the whole profile: nothing renders, and a warning explains why (also surfaced proactively by doctor, without needing an export/regenerate run first).

network.domains stays unmodeled even for a rendering-eligible config. Meaningfully rendering it also requires modeling network.enabled/ network.mode — Codex's network proxy is off by default under workspace-write, so a domain entry alone can be dead config — a bigger, separate sandbox/network-vocabulary gap this slice doesn't take on. approval_policy/sandbox_mode remain unmodeled too; Codex's permission profiles intersect with (never replace) those. See #1102.

Rules​

(added in v4.0.0)

Codex has no rules/*.md-with-glob-frontmatter convention the way Claude Code and opencode do — no per-file rule mechanism at all, conditional or otherwise. capabilities.rules bodies (frontmatter stripped) fold into the same merged content model_instructions_file points at, each preceded by a provenance comment naming its source bundle and file. This is a lossy transform: a rule's path-scoped, conditional application on Claude Code and opencode becomes unconditional AGENTS.md prose on Codex. See #1103.

Skills​

(added in v4.0.0)

Codex skills use the same SKILL.md convention (name + description frontmatter, plus body) that Claude Code does, so llmenv's existing SKILL.md validation carries over unchanged. Unlike Claude Code, Codex has no auto-discovery for a skills/ directory — each skill folder needs an explicit [[skills.config]] entry naming its absolute path with enabled = true, or Codex never sees it.

First-class skills (capabilities.skills) and the built-in llmenv skill are written under out/skills/ the same way as for Claude Code, then every subdirectory found there is registered — scanning the materialized directory, rather than tracking each skill by name through the several code paths that can write one, so a skill can never go unregistered just because a future writer forgot to also update a name list. See #1106.

Plugin-installation metadata (an analogue of installed_plugins.json) and LSP config are verified-absent from Codex's own source — not deferred work, and not expected to land later without Codex itself gaining the surface.

The native.codex escape hatch​

native.codex merges arbitrary keys into config.toml — useful for anything llmenv doesn't model yet (model, approval_policy, sandbox_mode, …). mcp_servers, model_instructions_file, hooks, permissions, default_permissions, and skills are rejected there, because each would clobber a block this adapter renders itself; use native_mcp.codex / native_hooks.codex to merge additively into the corresponding rendered block instead.

A null value deletes the key it targets, matching the other adapters. That matters more here than elsewhere: TOML has no null, so an unstripped one would fail the whole render rather than removing a key.

Other engines​

The capability model is engine-neutral by design, so additional adapters can render the same neutral config into their own shape and expose their own native_* overrides.