Provider mode¶
agedum <provider-name|config.json|.yaml> [--env <file>] [--dry-run] [--print-config] [harness args...]
Provider mode is the normal way to launch an agent. agedum reads a provider config
(JSON, or YAML declaring schema: agedum-provider/v1 or /v2), resolves the provider's
secrets from a
.env, sets the provider/model/auth environment, and launches the harness named in the config
— all in one process, inside the same virtual-file context wrapper mode uses.
There is no generated launcher script: the config is read at run time.
agedum claude-deepseek-auto # resolve the named provider, launch claude
agedum claude-deepseek-auto -p "review this" # extra args go to the harness
agedum claude/my-claude.json # relative to the providers root, not CWD
agedum /absolute/path/my-claude.yaml # absolute config path
agedum claude-deepseek-auto --dry-run # print the resolved env + argv, don't launch
agedum claude-deepseek-auto --print-config # print the effective config as YAML, exit
This page covers the mechanism shared by every provider: how the config and env resolve,
the config envelope, prompt-seeding, and --dry-run. The config block is per-harness
— each harness page has a working recipe and the full key mapping:
| Harness | Provider config |
|---|---|
| Claude | recipe + mapping |
| kimi | recipe + mapping |
| opencode | recipe + mapping |
| Cline | recipe + mapping |
| reasonix | recipe + mapping |
| aider | recipe + mapping |
| pi | recipe + mapping |
| codex | recipe + mapping |
Resolving the provider¶
The single positional argument is a config reference, resolved relative to the
providers root (${AGENTS_PROVIDERS_DIR:-~/.config/agents/providers}):
- a value starting with
/is an absolute filesystem path; - anything else is relative to the providers root — nested paths included, so configs may
be organised in subdirectories:
agedum claude/deepseek.json→<root>/claude/deepseek.json; - a value with no recognised extension tries
.jsonfirst, then.yaml, then.yml(agedum claude/deepseekalso works); - an explicit
.jsonreference that does not exist falls back to its.yamlsibling — so a base converted from JSON to YAML keeps every old referrer working, and.yaml/.ymlreferences resolve as-is; - a reference that resolves to no file is an error — there is no CWD or fallback search.
Run agedum --providers to list the launchable configs by their
path (e.g. claude/deepseek). A config's identity and label are its path — there is no
name field.
Any token after the provider that isn't an agedum flag is passed to the harness verbatim
(agedum claude-deepseek-auto -p "hi" runs claude -p "hi"). --env, --dry-run, and
--print-config are
agedum's own flags and may appear before or after the provider; to forward a literal
--dry-run/--env to the harness, put it after a --
(agedum claude-deepseek-auto -- --dry-run).
The env file¶
Secrets are read from ${AGENTS_ENV_FILE:-~/.config/agents/.env}, overridable per-run with
--env <file>. It is a simple KEY=VALUE file (an optional export prefix and
surrounding quotes are honoured; # lines and blanks are skipped). Every variable named in
the config's requiredEnv (plus secretEnv) must be present and non-empty, or agedum
fails fast with a clear message before launching.
Whitespace-prefixed trailing comments work after unquoted or closed quoted values;
# inside quotes stays literal. Quoted values are not shell-evaluated or expanded,
and backslashes remain literal. Unclosed quotes or unsupported text after a closing quote
fail with the variable name, never the value.
Nested provider launches clear only agedum-owned proxy controls (fold/translate, OpenAI cache/thinking and Codex chat-upstream switches), then apply their current settings. Unrelated user/auth environment variables remain inherited.
Unlike the retired --build-script codegen — which emitted a wrapper that sourced the
.env itself, so agedum never saw a token — provider mode reads the env file into the
agedum process and sets the resolved values in the child environment.
Config shape¶
The config is the condash-style agent envelope:
{
"harness": "claude",
"secretEnv": "DEEPSEEK_API_KEY",
"requiredEnv": ["DEEPSEEK_API_KEY"],
"config": { "...": "per-harness options" }
}
| Field | Meaning |
|---|---|
harness |
claude, kimi, opencode, cline, reasonix, aider, pi, or codex. Selects the translation and the harness to launch; read from the file (there is no --harness flag). |
secretEnv |
The env var holding the API token. Per harness: claude maps it to ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY; kimi / opencode / reasonix / aider / pi / codex pass it through under its own name (reasonix reads it via the provider's api_key_env, aider via litellm, pi by the conventional var name or a $VAR reference in a generated models.json, codex by the provider's env_key); cline passes it as --key. |
requiredEnv |
Vars validated and exported into the child. secretEnv is always appended if not listed. Declare a provider's API-key var here so a harness that reads it from the environment sees it. |
config |
The per-harness option block — see the harness page table above. |
extends |
Optional — a config reference or list of them; the named base(s) are deep-merged and this config's keys applied last. See Extending configs. |
include |
Optional — a config reference or list of them; shared fragments pasted into this config (composition, not inheritance). See Including fragments. |
promptTemplates |
Optional OpenCode-only mapping of template names to prompt strings; included abstract fragments may supply it. Agents select one through prompt._template, optionally overriding config.promptVars with prompt._vars. See OpenCode prompt templates. |
permissionTemplates |
Optional OpenCode-only mapping of names to shared permission objects without task; agents opt in explicitly and may attach literal task rules. See OpenCode permission templates. |
abstract |
true marks a base-only config: excluded from --providers and not launchable on its own. |
sandbox |
Optional write-confinement — writable mounts cover the launch directory, exact injection parents, harness state/config dirs, /tmp, and sandbox.readWrite; individual injected files stay read-only. See Filesystem sandbox. |
A config's identity and label are its path under the providers root — there is no name
field. Save the config at the path you want to launch it by (e.g.
~/.config/agents/providers/claude/deepseek.json), put the API token in
~/.config/agents/.env, then agedum claude/deepseek.json --dry-run to check it.
YAML configs¶
A config may also be a YAML document (.yaml or .yml). YAML configs are versioned:
the document must declare the envelope version in a top-level schema key:
# providers/claude/deepseek.yaml
schema: agedum-provider/v1
harness: claude
secretEnv: DEEPSEEK_API_KEY
requiredEnv:
- DEEPSEEK_API_KEY
config:
baseUrl: https://api.deepseek.com/anthropic
model: deepseek-v4-pro
schema: agedum-provider/v1is required. A missing or different value fails the load with an error naming the expected version.- A valid config yields exactly the same envelope the equivalent JSON would — the
schemakey is consumed by the loader, and everything downstream (extends merging, the per-harness mapping,--dry-run) is identical. agedum parses YAML; it never converts files, and JSON stays a permanent second input format. - JSON configs need no
schemakey and load exactly as they always have — the version key is a YAML-only requirement. - A YAML config may also declare
schema: agedum-provider/v2, the effort-carrier expansion opt-in. JSON documents carry v1 semantics forever: v2 is YAML-only.
The YAML boolean trap¶
YAML 1.1 reads an unquoted on / off / yes / no as a boolean, not a word. In a
config that is almost never what you mean — secretEnv: on, compaction: off, or an env
value extraEnv: {FOO: no} would reach the harness as "True"/"False". agedum therefore
rejects an unquoted boolean in any string-valued slot (env values, secretEnv /
requiredEnv entries, extends references, harness, and the string keys of the config
block such as model, baseUrl, compaction) with a named error:
yaml boolean trap at config.extraEnv.FOO: unquoted on/off/yes/no parsed as boolean — quote the value
Quote the value (secretEnv: "on") and it passes through verbatim. Booleans that are
booleans — abstract: true, foldSystemMessages: true, kimi's thinking, codex's
codexConfig flags — are untouched. ${VAR} placeholders are never interpolated by the YAML
reader; they survive verbatim exactly as in JSON.
Config features by engine version¶
Each row is an engine capability floor — the oldest agedum that loads a config using the feature; every later engine keeps it:
| Feature | Minimum engine |
|---|---|
YAML configs load + --providers listing |
0.58 |
include composition |
0.59 |
modelRef + the models.yaml catalogue |
0.60 |
carrierMeta tolerated in a catalogue (read only by v2 expansion) |
0.60 |
agedum-provider/v2 intent documents (carrier expansion) |
0.61 |
failoverIntent |
0.62 |
failoverIntent.wait (wait-for-the-limit-reset) |
0.66 |
One silent-downgrade trap: a 0.61 engine loads a failover-bearing v2 config and
silently runs it without failover — failoverIntent postdates 0.61, and the engine
ignores an unknown top-level key rather than refusing it. The same applies to
failoverIntent.wait on engines older than the release that introduced it: the authored
key is dropped, the launcher runs without wait, and no error surfaces (the numbered
capability floor row lands with that release).
Extending configs — extends¶
A config can extends one or more base configs and inherit their settings, so shared
options are written once. extends is a config reference (or a list of them), resolved the
same way as the launch argument — relative to the providers root, or absolute when starting
with /:
// providers/base/claude-deepseek.json (a shared base, not launched directly)
{ "abstract": true, "harness": "claude", "secretEnv": "DEEPSEEK_API_KEY",
"config": { "baseUrl": "https://api.deepseek.com/anthropic", "effortLevel": "max" } }
// providers/claude/deepseek.json
{ "extends": "base/claude-deepseek.json", "config": { "model": "deepseek-v4-pro" } }
agedum claude/deepseek.json then launches the merged config (base + child). Rules:
- Merge is a deep-merge: nested objects (like
config) combine key-by-key. With a list of bases, they merge left→right and the extending config is applied last (child wins). requiredEnvunions instead of being replaced — it is the one exception to child-wins. Lists otherwise replace wholesale, which would mean a child declaring its ownrequiredEnvsilently drops the base's: the base's var would go unvalidated and unexported, and whatever it configured (an MCP${VAR}, a provider key) would fail at first use rather than at launch. Requirements accumulate down the chain — base order first, the child's additions appended, duplicates dropped.- Recursive — a base may itself
extendsanother. - Formats mix freely — each reference resolves by the rules above, so a YAML child may
extend a JSON base and vice versa (an explicit
base/x.jsonreference even keeps working afterxis converted to YAML, via the.yamlsibling fallback). - A cycle (a → b → a) or a base that resolves to no file is an error.
abstract: truemarks a config as a base only: it is skipped by--providersand refuses to launch directly (agedum base/claude-deepseek.jsonerrors). Abstractness is not inherited — a config that extends an abstract base is itself launchable.
Including fragments — include¶
extends expresses two different relationships today: real inheritance (a child specialising a
base) and plain fragment sharing (a config extending a base just to paste in its MCP block).
include is the second, said directly: it pastes a shared fragment in without making it a
prototype. Like extends, it takes a config reference or a list of them, resolved by the same
rules (.json/.yaml fallback included), and every reference of
either kind may point at either format.
The merge order for one config is, most-default first: every include target (each
recursively resolved, its own include/extends already applied), deep-merged left→right —
earlier include is the more default; then the extends chain, whose keys beat an included
fragment's on conflict (inheritance overrides composition); then the file's own keys —
requiredEnv unions across all three layers:
// providers/base/mcp-nodum.json (a shared fragment, not a prototype)
{ "abstract": true, "requiredEnv": ["NODUM_AGENT_TOKEN"],
"config": { "mcpServers": { "nodum": { "command": "nodum", "args": ["mcp", "serve"] } } } }
// providers/claude/opus.json
{ "include": "base/mcp-nodum.json", "harness": "claude",
"config": { "settings": { "model": "opus" } } }
Composition also carries v2 intent, and YAML declares the fragment abstract directly. This is the production pattern — a shared pool fragment included by every launcher in a harness family:
# providers/oc/_pool.yaml — the shared fragment
schema: agedum-provider/v2 # every YAML file declares a version — fragments too
abstract: true # stays out of --providers, refuses a direct launch
harness: opencode
config:
opencodeConfig:
compaction:
auto: true
# providers/oc/ds-flash.yaml — one launcher in the family
schema: agedum-provider/v2 # the entry document's schema is the one that gates
extends: base/conception-sandbox.yaml
include: oc/_pool.yaml
requiredEnv: [DEEPSEEK_API_KEY]
config:
model: ds-flash@high # expansion intent rides through composition unchanged
providerDef:
- id: ds
npm: "@ai-sdk/openai-compatible"
baseUrl: https://api.deepseek.com
apiKeyEnv: DEEPSEEK_API_KEY
Version gating reads the entry document's declared schema: include targets are
resolved and merged first, the entry file's own schema then selects v1/v2 handling,
and any file in an include/extends chain may declare either version — a fragment's
or base's schema never gates the launcher (when expansion runs).
Rules:
- Composition only — an included fragment is not a prototype:
abstractis not inherited through an include, and an included target is not "applied" in any launch sense. Give fragmentsabstract: true(as above) so they stay out of--providersand refuse a direct launch, exactly like bases. includeis a meta key likeextends— consumed during resolution, never present in the merged result.- Cycles are detected across the combined include+extends graph: a file included twice
through different paths is fine (a DAG merge — it simply merges twice, idempotently), but a
cycle (
aincludesb,bincludesa; oraextendsb,bincludesa) is an error, as is a reference that resolves to no file or a target that parses to a non-mapping. - All the per-file checks apply to fragments too — a YAML fragment's
schemakey and boolean-trap walk run on the fragment's own file, so a trap inside an included fragment names that fragment's path. --dry-runoutput is unchanged: composition detail is not reported, the effective result is.
Model catalogue — models.yaml + modelRef¶
A hand-written opencode launcher that defines a custom provider must inline a catalog block
per model (the model's name, limit, modalities, attachment — the block opencode itself
consumes) under config.opencodeConfig.provider.<id>.models. When several launchers serve the
same models, that block is duplicated in each. The model catalogue is a shared,
run-time source for those fragments: a models.yaml at the providers root, and a modelRef
on a config.providerDef entry that pulls one entry in at launch.
# providers/models.yaml (fixed filename at the providers root)
schema: agedum-models/v1
models:
deepseek-v4-pro:
name: DeepSeek V4 Pro
limit:
context: 1000000
output: 65536
deepseek-flash:
name: DeepSeek V4.1 Flash
attachment: true
limit:
context: 1000000
output: 65536
modalities:
input: [text, image]
output: [text]
# providers/oc/hand-ds.yaml — the catalog block comes from the catalogue
schema: agedum-provider/v1
harness: opencode
secretEnv: DEEPSEEK_API_KEY
config:
model: deepseek/deepseek-v4-pro
providerDef:
id: deepseek
npm: "@ai-sdk/openai-compatible"
baseUrl: https://api.deepseek.com
apiKeyEnv: DEEPSEEK_API_KEY
modelRef: deepseek-v4-pro # a catalogue id, or a list of them
At launch — after include/extends merging, before the launch builds — each modelRef
expands to the catalogue entry filed under
config.opencodeConfig.provider.<providerDef id>.models.<id>, exactly where the generated
oc/*.json configs carry their catalog inline; an equivalent config with the block written by
hand merges to the same result, and --dry-run shows the expanded form. The entry vocabulary
is opencode's own — whatever opencode consumes per model (variants, options, …) can
live in the catalogue and rides through verbatim.
Rules:
- Opt-in, zero-change otherwise — a config with no
modelRefanywhere and nomodelsCatalognever touches the catalogue (which need not exist), and the generatedoc/*.jsonconfigs are unaffected. - The catalogue is data, not a config:
--providersskips the fixed root-levelmodels.yaml(only that exact filename — amodels.yamlin a subdirectory stays an ordinary config candidate). - Errors are named: a catalogue missing, carrying a wrong
schema:value, or holding a malformed entry (checked minimally:namestring,attachmentboolean,limitintegers or null,modalitiesstring lists — errors name the model id and key) raises aProviderErrornaming the catalogue path; amodelRefmissing from the catalogue names the ref, the providerDef, and the path. A config carryingmodelRefon any harness other than opencode is refused with a message naming the harness (not the ref or path). - Override the location with a top-level
modelsCatalog: <ref>(resolved likeinclude; YAML-only — a non-.yamlref is an error). It is a meta key likeextends/include, consumed and stripped, and a declared pointer loads and validates even when nothing references it. - No YAML boolean-trap walk on the catalogue in v1 (a documented limit): the catalogue's
per-model entries are passed through verbatim, so quote any value that reads as
on/off/yes/noyourself. - opencode first — claude and codex need no expansion today; a
modelRefon their configs fails loudly rather than being ignored. carrierMeta— the catalogue may carry an optional top-level facts section read by v2 expansion;modelReffiling never reads it, so a catalogue withcarrierMetafiles byte-identically on every engine ≥ 0.60 (older engines ignore the section entirely — the schema staysagedum-models/v1).
Expansion — agedum-provider/v2¶
schema: agedum-provider/v2 is the effort-carrier expansion opt-in. A v2 document is
byte-shaped exactly like a v1 document — same envelope, same authored policy blocks
(prompts, permissions, providerDef, requiredEnv all stay authored verbatim) — with
these deltas:
schema: agedum-provider/v2(the opt-in itself);- agent entries and
config.modelmay declaremodel: <catalogue-key>@<effort>refs; - an optional top-level
expansionModels:list of the same refs declares universe members with no agent entry (failover-only models and efforts). It is consumed likemodelsCatalog— stripped before the launch; - an optional top-level
failoverIntent:block declares run-time failover intent (derived into the effectivefailoverblock from the config's own agents). It is consumed likeexpansionModels— stripped before the launch.
The engine derives everything carrier-specific; the author writes no variant,
no options.reasoningEffort, no alias plumbing:
schema: agedum-provider/v2
harness: opencode
config:
model: ds-flash@high # an @-ref — translated by the same rule as agents
opencodeConfig:
agent:
ds-flash-high:
mode: subagent
model: ds-flash@high # intent: derived options.reasoningEffort: high
ds-flash-low:
mode: subagent
model: ds-flash@low
providerDef: # authored plumbing — never invented by the engine
- id: ds
npm: "@ai-sdk/openai-compatible"
baseUrl: https://api.deepseek.com
apiKeyEnv: DEEPSEEK_API_KEY
expansionModels: [k3@low] # universe member with no agent entry
When expansion runs¶
- The root document's declared schema gates it: a v1 base under a v2 root expands, a
v2 base under a v1 root does not (per-file checks are unchanged — any file in an
extends/includechain may declare either version). - It runs after
modelReffiling, before the launch builds — so--dry-runand--print-configshow the effective result. Derived catalog entries merge under whatmodelReffiled or the author wrote inline (authored wins on conflict, the same rulemodelRefapplies). - JSON documents are v1 semantics forever: v2 is YAML-only.
- A v1 document carrying intent is a named load error. An opencode
config.modelor agentmodelstring containing@— or a top-levelexpansionModelsorfailoverIntent— in a v1 document fails the launch with "this config looks like expansion intent …; declareschema: agedum-provider/v2", instead of launching a garbage model ref that fails far from the cause. No success path changes: intent slots only. - Non-opencode harnesses: a v2 config with intent markers on any harness other than
opencode is refused (the
modelRefopencode-first rule). A v2 config with no markers (@-refs,expansionModels, andfailoverIntentall absent) is a no-op — declaring v2 alone is not intent. - An authored
failoverblock passes through untouched; a v2 document carrying both it andfailoverIntentis a named expansion error (two declarations of the same block — the engine refuses to guess which wins). See Failover.
carrierMeta — the catalogue's facts section¶
Expansion resolves refs against the model catalogue extended with an
optional top-level carrierMeta section — per-model facts, separate from the verbatim
catalog fragments:
# providers/models.yaml (continued)
carrierMeta:
ds-flash:
provider: ds # provider id catalog entries file under
family: deepseek # selects the effort carrier
efforts: [high, low] # what this model accepts; medium is opt-in per model
display: DS Flash # display-name fact (may differ from the fragment's name)
vision: true # failover vision-map fact (read by `failoverIntent` expansion)
k3:
provider: kimi-coding
family: kimi
efforts: [high, low]
display: Kimi K3
aliases: {high: k3, low: k3-low} # model-alias families only
alias_model_id: k3 # required iff `aliases`
vision: true
Entries are validated whenever the catalogue loads (named ModelCatalogSchemaErrors):
provider/family/display non-empty strings, efforts a non-empty list inside the
effort alphabet (high, medium, low in canonical order), aliases a mapping of alphabet
efforts to non-empty strings, alias_model_id required iff aliases is present. A model
accepts medium only when its own efforts lists it; existing high/low-only entries
stay unchanged. A model-alias entry needs an alias for each effort actually referenced,
not every effort declared in efforts. Unknown keys inside an entry
are ignored — future facts land here without a catalogue change. vision (a boolean) is
one of those permissive facts: read only by failoverIntent
expansion, ignored everywhere else. modelRef filing never reads the section, so the
models map is byte-identical on every engine ≥ 0.60 (older engines ignore carrierMeta
entirely).
Per-carrier semantics¶
The universe is every @-ref in config.model and the opencodeConfig.agent model
fields, plus expansionModels. Providers file their catalog entries in
first-appearance order of their models' refs (agent entry order, then
expansionModels) — no order constant lives in the engine. config.model @-refs are
translated by the same ref function (authoring sugar); plain provider/model strings
pass through untouched.
For config.model refs, DeepSeek/GLM/GPT effort is carried in
defaultOptions.reasoningEffort, then filed on the selected model's native options.
GPT has no top-level default variant selector: model options provide its default wire effort,
while explicit agent variants and options keep precedence. Explicit same-model GPT agent
variants remain enabled in the derived variant map, including a model supplied by
agentOptions and a variant supplied by opencodeConfig.agent. Preservation follows the
runtime merge: modeled rows are built in order, then native agent entries deep-merge last
and win conflicting model selections. Mixing an @ default with an authored
effortLevel or defaultOptions.reasoningEffort is an expansion error. Kimi still selects
the alias and keeps its existing thinking options.
| Family | model: key@effort derives |
Catalog entry (filed under provider.<provider>.models) |
|---|---|---|
| deepseek, glm | model: provider/key + options.reasoningEffort: effort (merged into an authored options map) |
the fragment, verbatim |
| gpt | model: provider/key + variant: effort |
the fragment + variants: disabling every OpenCode variant the config does not declare (vocabulary order; declaring astra@medium leaves medium enabled only when that model's efforts includes it) |
| kimi | model: provider/<aliases[effort]> — the agent entry carries no carrier fields |
one alias-keyed entry per declared effort (canonical high, medium, low order), each options.thinking: {type: enabled, effort}; the low entry is rebuilt as {id: alias_model_id, name: "<display> (low thinking)", …} |
Failover — failoverIntent¶
A v2 config may author its failover as intent instead of writing the failover
block directly — chain sources and rungs as explicit refs in the key@effort grammar:
schema: agedum-provider/v2
harness: opencode
failoverIntent:
detect: # authored data — copied verbatim, never interpreted
status: [429, 402]
messages: [usage limit, quota, rate limit]
maxWalk: 3
wait: # authored data — wait-for-the-limit-reset opt-in
maxWaitHours: 8
probeSeconds: 3600
chains: # explicit key@effort refs against catalogue keys;
sol@low: [glm-flash@high] # optional when `wait` is authored (the wait-only shape)
terra@low: [glm-flash@high]
Expansion derives the effective top-level failover block from the config's own
agents and intent at load time — nothing precomputes it — then strips the intent
(consumed, like expansionModels):
- Roster —
mode: primaryagents are the mains,mode: subagentthe workers, any other or absent mode is in neither; an agent whosemodelis a plainprovider/modelstring contributes no pair. The pair, not the agent id, is the roster unit. - Every intent ref resolves loud first — a ref that does not parse or names an unknown key/effort/model is a named expansion error before any filtering (resolution errors are authoring errors; they never depend on what the filter would later do).
- Filtering — a chain whose source is outside mains ∪ workers drops; a chain left
without rungs drops; zero surviving chains omit the whole block — no
failoverkey in the effective config — unlesswaitis authored: the wait-only shape survives withchains: {}/rungOptions: {}and the derivedvision(the walk then runs the primary alone and waits at exhaustion). Absence means ignore, never an error: a hand config may inherit an intent whose chains all drop — withoutwaitit launches without failover, withwaitit launches wait-only. - Universe — the surviving chains' rung refs join the expansion universe after
expansionModels, in authored order; dropped chains contribute nothing to filing. A rung-only model therefore files with noexpansionModels(the key is subsumed in any failover-bearing config; the two keys may also be combined — the universe is their union). - Translation — rung and source refs translate per carrier:
provider/key@effort(variant / reasoningEffort families), the bareprovider/<aliases[effort]>(kimi). Two surviving sources translating to the same runtime ref would be a named error. rungOptions— one{"reasoning_effort": effort}entry (snake_case — agent entries'options.reasoningEffortstays camelCase; the two conventions coexist in one block by design) per used variant/reasoningEffort rung, in canonical effort order (high,medium,low). Model-alias rungs never appear.vision— derived from the catalogue'scarrierMeta.visionfacts: one entry per universe model, walked provider-major in first-appearance order; model-alias models additionally get one entry per declared effort atprovider/<aliases[effort]>. A universe model whosecarrierMetalacks the fact — or carries a non-boolean one — is a named error — but only when a block is actually derived (an omitted block demands no vision facts).
detect/maxWalk/wait are authored data copied verbatim, never interpreted or validated
at expansion — launch-time validation polices the emitted block for derived and authored
blocks alike (wait's semantics live at
failover.wait). The engine filters and omits; it
never enforces that a config's intent names its roster — that invariant stays authoring-side.
The key collision rules, per root schema:
| root | keys present | behaviour |
|---|---|---|
| v1 | failover |
passthrough — unchanged |
| v1 | failoverIntent |
the named intent load error (declare v2) |
| v2 | failoverIntent only |
intent expands; key stripped; derived block emitted |
| v2 | failoverIntent + failover |
named expansion error — two declarations of the same block |
| v2 | failover only |
passthrough untouched |
| either | neither | nothing — the omission rule |
Errors are named¶
A ref that cannot resolve is an ExpansionError naming where it sits: unknown catalogue
key; effort outside the alphabet (high/medium/low); effort outside the model's efforts;
a referenced model with no carrierMeta entry; a family with no effort carrier; a
model-alias model without aliases/alias_model_id; a ref effort missing from
aliases; an agent entry that authors a carrier field (variant or
options.reasoningEffort) on a model whose ref also carries @ (the engine refuses to
guess); @-refs on a non-opencode harness. One authoring trap gets its own message: a
bare catalogue key in config.model (model: ds-flash — no @, no /) is not a
ref and is not silently passed through; declare the effort or write the plain
provider/model form. expansionModels must be a list of key@effort strings.
failoverIntent adds its own named errors: a malformed block or chains shape; a
universe model whose carrierMeta has no vision fact (when a block is derived); a v2
document declaring both failoverIntent and an authored failover block.
--print-config¶
Prints the effective merged+expanded config as YAML — include/extends resolved,
modelRef filed, v2 intent expanded, OpenCode prompt and permission templates rendered — and exits 0. No env resolution, no
requiredEnv validation, no launch; the config document is the whole output, so the flag
works without an env file:
This is the debug/parity view of exactly what a launch would see (--dry-run shows the
same effective config inside the full launch view). Note --print-config prints the
config; env-var references (apiKeyEnv, requiredEnv, ${VAR} placeholders) appear
as authored, never resolved.
For OpenCode agents, it prints the rendered base prompt and a separate
agentAppend if present; the launch builder alone folds that append into
OPENCODE_CONFIG_CONTENT and strips its synthetic key.
MCP servers — config.mcpServers¶
config.mcpServers declares MCP servers in one canonical vocabulary that agedum
translates into each harness's own dialect, so a server is written once and extended onto
every launcher that should carry it. Canonical translation is supported by claude,
opencode, and codex; kimi keeps a verbatim passthrough (with the caveat below).
Other harnesses ignore the key.
An entry is either stdio or remote — never both:
"mcpServers": {
"nodum": { "command": "nodum", "args": ["mcp", "serve"],
"env": { "NODUM_AGENT_TOKEN": "${NODUM_AGENT_TOKEN}" }, "cwd": "/opt/x" },
"buffer": { "url": "https://mcp.buffer.com/mcp", "transport": "http",
"headers": { "Authorization": "Bearer ${BUFFER_KEY}" } }
}
| Canonical | claude | opencode | codex |
|---|---|---|---|
stdio {command, args, env, cwd} |
passed through as-is | {type:"local", command:[command, …args], environment, cwd, enabled:true} |
dotted mcp_servers.<name>.command, .args, .env.<key>, .cwd |
remote {url, headers, transport} |
{type: transport\|"http", url, headers} |
{type:"remote", url, headers, enabled:true} |
dotted .url / .headers; no transport override |
| delivery | --mcp-config '<json>' appended to argv |
merged into OPENCODE_CONFIG_CONTENT |
repeatable -c key=<toml> argv overrides |
${VAR} placeholders¶
For claude and opencode, values may carry a ${VAR} placeholder.
agedum respells it, it never resolves it —
resolving would bake the secret into argv (claude) or into an env var (opencode), where the
process list and --dry-run would expose it. Instead each harness expands it itself:
claude reads ${VAR} natively, and opencode's spelling {env:VAR} is written for it.
Codex and kimi reject ${VAR} placeholders, rather than pass an unexpanded token.
Do not reuse this placeholder-bearing example unchanged for those harnesses.
The variable still has to reach the harness, which means naming it in requiredEnv —
that is what copies it out of ~/.config/agents/.env into the child environment. Declaring
it there also makes a missing token fail at launch rather than at first tool call:
{ "abstract": true,
"requiredEnv": ["NODUM_AGENT_TOKEN"],
"config": { "mcpServers": { "nodum": { "command": "nodum", "args": ["mcp", "serve"],
"env": { "NODUM_AGENT_TOKEN": "${NODUM_AGENT_TOKEN}" } } } } }
Extend that base from any claude or opencode launcher and both the server and its env requirement come along.
Per-harness notes¶
- claude —
--strict-mcp-configis deliberately not passed, so provider-declared servers are additive to the user's own (~/.claude/settings.json, project.mcp.json), not a replacement. - opencode — the translated block is merged before
opencodeConfig, so an explicitopencodeConfig.mcpentry still overrides one server without abandoning the shared base. Note opencode resolves an unset{env:VAR}to the empty string, which is a second reason to declare the var inrequiredEnv. - codex — canonical stdio and remote entries become
-c mcp_servers.<name>…TOML overrides, layered over its own config.${VAR}placeholders are rejected. Literal env/header values go into argv, so do not put secrets there; see Codex MCP for a non-secret recipe. - kimi — keeps the older verbatim passthrough into
mcp.json(kimi § MCP), and its remote form is{url, bearerTokenEnvVar}rather thanheaders. Kimi Code is not known to expand${VAR}there, so a placeholder in a kimimcpServersentry is a fail-loud error rather than a literal handed to the server.
Filesystem sandbox — sandbox¶
An optional top-level sandbox block confines what the launched harness can write.
Without it, the harness shares your whole filesystem read-write — the namespace isolates only
what the harness reads as config, not where it can write. With it, the host is mounted
read-only and the harness can write only to the launch directory (the current dir —
the working tree it was launched in; the walked-up project root is used for finding sources
and injection targets, never as the writable grant, so a launch from a home subdir cannot
mount the whole home writable), its own state/config dir (agedum knows each harness's dir —
~/.claude, ~/.cline, ~/.codex, … — and always
makes it writable for state), the prepared exact parent of each injection target
(never a nearest-existing ancestor), a private /tmp, and each path in readWrite.
Read-only file overlays stay read-only inside otherwise writable directories:
{
"harness": "claude",
"slug": "claude-boxed",
"secretEnv": "DEEPSEEK_API_KEY",
"config": { "...": "..." },
"sandbox": {
"readWrite": ["~/notes", "${PROJECT_ROOT}/build"]
}
}
readWrite paths are templates resolved at launch: ~ → your home, $VAR → the
environment, ${PROJECT_ROOT} → the launch directory. An entry holding a shell glob
(*, ?, […]) is expanded against the filesystem and every existing match is added — so
~/src/* makes each immediate subdirectory of ~/src writable (the ~/src dir itself is not
bound, and an unmatched glob adds nothing). A path already inside the launch directory is
redundant (the launch dir is always writable) and folded in. An empty "sandbox": {} still
confines — only the always-writable set applies. This is the provider-mode equivalent of
wrapper mode's --sandbox / --rw-dir; agedum <name> --dry-run lists
the resulting writable set under a sandbox · write-confinement heading. It confines the
filesystem only — the network is untouched, so the harness still reaches its endpoint.
Linux-only, like the rest of the launch.
Seeding an initial prompt — --prompt / --run¶
Two agedum flags seed the launched harness with a first prompt, abstracting over each harness's own prompt syntax (mutually exclusive, each given once):
--prompt "<text>"— launch interactively with<text>as the first message; the session stays open.--run "<text>"— run<text>non-interactively and exit. The form for scripts and tasks.
agedum maps the flag to the harness named in the config:
| Harness | --prompt (interactive) |
--run (non-interactive) |
|---|---|---|
| claude | positional prompt: claude "<text>" |
claude --print "<text>" |
| kimi | (unsupported — fail-loud) | kimi --prompt "<text>" |
| opencode | opencode --prompt "<text>" |
opencode run "<text>" |
| cline | cline --tui "<text>" |
cline "<text>" |
| reasonix | (unsupported — fail-loud) | reasonix run "<text>" |
| aider | (unsupported — fail-loud) | aider --message "<text>" |
| pi | positional prompt: pi "<text>" |
pi --print "<text>" |
| codex | positional prompt: codex "<text>" |
codex exec "<text>" |
For cline the prompt is a positional argument either way; --tui is what opens the
interactive TUI (seeded with the prompt), and a bare positional runs the task once in act
mode and exits. For kimi, reasonix, and aider only --run is supported — Kimi Code's
--prompt runs once and exits (and cannot combine with --yolo/--auto, which --run
therefore drops), reasonix's run subcommand and aider's --message each take the task and
exit, but none has an interactive prompt-seed (reasonix's chat can't be pre-seeded; aider's
--message exits), so --prompt is a fail-loud ProviderError (condash then falls back to
spawn-and-type for an interactive seed).
A harness with no known prompt-seeding convention is a fail-loud ProviderError (agedum
never guesses). Harness passthrough args are preserved, before the prompt text.
Because --run is non-interactive, agedum runs the harness with /dev/null for stdin
so it can never block on input it will never receive — notably opencode run, which hangs
forever on an open, non-tty stdin (a pipe, a headless task runner). --prompt keeps the
inherited stdin for the live session.
--dry-run¶
Prints the full resolved launch without running it, so you can see exactly what context the
harness is given. It names the config's source format (source yaml / json) and is
grouped by scope (project / global); under each, every source
(AGENTS.md, .agents/skills/) is listed with its disposition: → <dest> when
injected, read in place when the harness reads it natively, or an explicit note when a
scope contributes nothing. Project-scope
paths display relative to the cwd; global-scope stays ~-absolute. The resolved config (env
vars; opencode's OPENCODE_CONFIG_CONTENT pretty-printed; secrets masked) and the final
command are shown too. Diagnostic JSON, generated TOML and structured argv values are parsed,
redacted before serialization, and rendered without changing the real launch data. TOML
diagnostics may use equivalent inline tables rather than the original file layout. Numeric
and boolean fields stay typed, env references remain visible, and required switches such as
1 do not rewrite digits inside unrelated prompts or JSON. Unparseable generated content is
withheld instead of printed raw. Local-proxy capabilities are runtime-only and never printed.
For a kimi provider run from a project root:
provider Kimi
harness kimi
source yaml
env file ~/.config/agents/.env
project scope · ~/src/foo
AGENTS.md read in place (read natively — not injected)
.agents/skills/ → .kimi-code/skills/
global scope
~/.config/agents/AGENTS.md → ~/.kimi-code/AGENTS.md
~/.config/agents/skills/ → ~/.kimi-code/skills/
command
kimi
For an opencode provider the resolved config is shown as indented JSON (the
environment section), and a scope with no sources is stated explicitly, e.g.:
environment
OPENCODE_CONFIG_CONTENT
{
"model": "deepseek/deepseek-v4-pro",
"provider": { "deepseek": { "models": { "deepseek-v4-pro": { "options": { "reasoningEffort": "max" } } } } }
}
project scope · ~
(no AGENTS.md or .agents/skills found here)
global scope
~/.config/agents/AGENTS.md → ~/.config/opencode/AGENTS.md
~/.config/agents/skills/ → ~/.config/opencode/skills/
This is the same view wrapper mode shows — how agedum renders the agent-neutral source for the harness — plus the resolved provider environment. Dry-run uses temporary compilation and does not seed provider files or prepare host mount/state directories. On an actual launch, read-only injected content is exposed at the listed destinations only inside the process's mount namespace, but writable Kimi/Cline seeds persist on the host. Prepared directories and harness state/transcripts can also remain; empty mountpoint stubs are swept only when newly created and still empty.