Merge Rules
Field-by-field precedence across the three configuration layers — what replaces, what merges shallowly, what merges deeply, plus interpolation order and the Claude settings merge
Order of operations
global profile ~/.heretic/agents/<name>.yaml
↓ merge
local override .heretic/cli/<name>.yaml (or legacy agent.yaml with a matching extends)
↓ merge
CLI overrides --mcp, --root, --sidecar / --builder-image / --disable-sidecars
↓
secrets resolved (scripts run, $VAR references read)
↓
${VAR} interpolation over every string in the merged object
↓
mcp_file merged with inline mcp (by name)
↓
defaults applied → validation
Two consequences worth internalising: interpolation happens after merging, so an override can introduce a ${VAR} the profile never used; and validation runs last, on the fully merged and interpolated object.
Field strategies
| Field | Strategy | Notes |
|---|---|---|
image | replace | |
runner | replace | docker, compose, custom |
agent_type | replace | claude, aider, copilot-cli, generic |
provider | replace | anthropic, thirdparty, copilot |
volumes | replace | the whole array — re-list every mount |
env | shallow merge | per key; later layer wins |
workdir | replace | |
command | replace | normalised to an array |
interactive | replace | |
tty | replace | |
extra | shallow merge | per key, with the exceptions below |
extra.ports | replace | all-or-nothing |
extra.capabilities | replace | all-or-nothing |
extra.labels | shallow merge | per label |
compose | deep merge | objects recurse; arrays and primitives replace |
ssh | shallow merge | per key |
tool_backends | shallow merge, sidecars replaced | scalars such as workspace_target and ready_timeout merge |
mcp | replace | the whole array |
mcp_file | replace | |
mcp_override | replace | |
git | shallow merge | per key |
dind | replace | |
secrets | shallow merge | per key |
claude_settings | replace |
Strategy definitions
Replace — the later value wins entirely. If the override sets image, the profile's image is ignored.
Shallow merge — top-level keys are combined; on a conflict the later layer wins. Nested objects are not merged recursively.
# global profile
env:
NODE_ENV: production
API_URL: https://api.example.com
# local override
env:
NODE_ENV: development
DEBUG: "app:*"
# result
env:
NODE_ENV: development # override wins
API_URL: https://api.example.com # kept from the profile
DEBUG: "app:*" # added
Deep merge — objects recurse at every level; arrays and primitives still replace. Used only for compose.
The replace traps
# WRONG — the workspace mount disappears
extends: claude
volumes:
- { source: "${HOME}/.gitconfig", target: /home/agent/.gitconfig, readonly: true }
# RIGHT — re-list everything you need
extends: claude
volumes:
- { source: "${CWD}", target: /workspace }
- { source: "${HOME}/.gitconfig", target: /home/agent/.gitconfig, readonly: true }
The same applies to mcp (add one server locally and the profile's servers are gone), extra.ports and extra.capabilities. env, secrets, git, ssh and extra are additive, so single-key changes are safe there.
CLI overrides
| Flag | Effect on the merged config |
|---|---|
--mcp <value> | replaces mcp |
--root | sets extra.run_as_root: true — only when passed, so it never clears a profile value |
--sidecar <rt> | replaces tool_backends.sidecars |
--builder-image <rt>=<img> | rewrites the image of that runtime, including sidecars declared in the profile |
--disable-sidecars | sets tool_backends.sidecars: [], winning over --sidecar |
Interpolation context
${VAR} is resolved from, in this order of specificity: CWD (the project directory), HOME, every variable in the host environment, and every resolved secret.
- an unknown variable produces
""and a warning - an empty value is then stripped from the container environment, unless the key was explicitly
""inenv $${LITERAL}escapes to${LITERAL}- a bare
$VARis left untouched in configuration values (it is meaningful insidesecrets:)
Claude settings merge
The profile's claude_settings file and the project's .heretic/cli/claude-settings.json follow their own rules, and the result is written into the session directory:
| Value type | Strategy |
|---|---|
arrays (permissions.allow, permissions.deny, allowedTools) | union, de-duplicated |
objects (env) | shallow merge; local wins |
| primitives | local replaces global |
Verifying the result
heretic-cli agents show <name> --resolved # merged, interpolated, secrets masked
heretic-cli local-validate <name> # the complete resolved object
heretic-cli -V run <name> # verbose: which layers were used
Next Steps
- Local Overrides — practical override recipes
- Validation — the rules applied after merging
- Environment Variables — what the container ends up with