How configuration works
agentd's configuration is one nested document assembled from layers — built-in defaults, files, environment variables and flags — and checked whole before anything runs. This page explains how the layers combine, where files are found, what an instruction document may add, how secrets stay out of files, and how a running daemon takes a new configuration. It is for operators and authors who want to predict which value is in effect and why. Every key is listed in the Configuration reference.
One document, three spellings
There is one document of truth, and everything you can type is a path into it. Every path has three spellings, derived from the schema rather than wired by hand, so a new setting gets all three at once:
| Source | The path limits.run.steps |
|---|---|
| a file (YAML or JSON) | limits: {run: {steps: 40}} |
| the environment | AGENTD_LIMITS_RUN_STEPS=40 |
| a flag | --limits.run.steps 40; ., _ and - are interchangeable, so --limits-run-steps 40 is the same flag |
Values are typed by the schema: numbers and booleans parse, an enum is
checked against its set, a list takes [a, b] or a, b, an object takes a
{k: v} literal. A short set of named aliases — --instruction,
--intelligence, --model, --mcp, --deadline and others — spell common
paths more briefly; agentd --help lists them all, and agentd --config-schema prints the schema itself.
Layers and precedence
The layers apply in a fixed order, each overriding the one before it key by key: a layer touches only the keys it sets, so an unset variable never clobbers a file's value.
built-in defaults < files < environment < flags
Three details of the order matter in practice:
- Inside the environment layer, a path name beats an alias.
AGENTD_INTELLIGENCE_MODELwins overAGENTD_MODELfor the same field, because it names the field exactly. A stale variable can therefore look ignored; check there first when a value surprises you. - A flag that names a path sets it; a repeatable alias adds to it.
--mcp-servers '[…]'orAGENTD_TOOLS_DISABLED=a,breplace the whole list the files declared. The three repeatable aliases —--mcp,--a2a-peerand--workflow— each append one entry instead. - A flag reaches only where the schema reaches. It can set one entry of a
free-form map, keeping the key's exact spelling
(
--intelligence.headers.x-team ops), but addressing an array element is a named error, not a guess.
agentd --effective-config prints the assembled document with the layer
that set each value, so "who said so?" is a command rather than a search.
It runs on a broken configuration too, reports errors beside the document,
and redacts credentials.
Where files come from
A file you name — -c/--config (repeatable) or AGENTD_CONFIG
(a :-separated list) — is a decision you made, and only those files load.
When you name none, agentd walks a discovery chain the way a linter
finds its dotfile, and loads every rung that has a file:
| Rung | Looked for | For |
|---|---|---|
| user | $XDG_CONFIG_HOME/agentd/config.yml (or .yaml), else ~/.config/agentd/config.yml | defaults that follow the person |
| project | ./agentd.yml (or agentd.yaml, .agentd.yml, .agentd.yaml) | the config a repository checks in |
| local | ./agentd.local.yml (or .yaml) | one machine's overrides, kept out of version control |
They merge in that order, with the environment and flags still on top. A
checked-in project file makes agentd work with no flags at all, and
pointing one checkout at a development endpoint never means editing a
tracked file. Four rules keep discovery from surprising you:
- It is only a fallback. Naming a config by flag or environment switches it off entirely.
- It looks in the working directory only, never in a parent or
/etc. - Two spellings of one rung is an error (exit 2).
agentd.ymlbesideagentd.local.ymlis the design;agentd.ymlbesideagentd.yamlis a coin toss agentd refuses to make. - A discovered file may not relax a security control. One that sets
security.allow_trifectaorsecurity.exec.enabledis refused, naming the file; pass it with--configif you meant it. A discovered file that sets where credentials go or who may connect — model endpoints, providers, tokens, MCP servers, the store, listeners, peers,security— is named in aconfig.warningat startup, so adopting a dotfile is never silent.
{"event":"config.warning","msg":"adopted the discovered config ./agentd.yml, ./agentd.local.yml (no --config given); it sets intelligence.endpoints"}
--help, --version and the schema dumps never discover a file, so a broken
one cannot stop you reading the help.
Conventional folders beside the config fill in settings you did not
write, so a project can be a directory rather than one long file:
workflows/ (one workflow per file), skills/ (one skill per Markdown file
or <name>/SKILL.md), subagents/ (one template per file) and context/
(named system-prompt templates). They are looked for beside each loaded
config file, the last one first, or in the working directory when none was
loaded. They apply only when the setting is absent — writing workflows: []
is a decision, and the folder does not argue with it — and only when the
folder yields something.
Several files compose
Several files merge into one document with JSON Merge Patch semantics
(RFC 7396): objects merge key by key, scalars and lists are replaced by the
later file, and an explicit null removes a key. Each file is type-checked
on its own first, so an unknown key names the file it is in.
# base.yaml
agent:
name: triage
instruction: You triage incoming issues and summarise what changed.
intelligence:
endpoints: https://api.openai.com/v1/chat/completions
model: gpt-5.1
token: "{{secret:OPENAI_API_KEY}}"
limits:
run: {steps: 50}
subagents: {depth: 4}
tools:
disabled: [memory.delete]
observability:
log_level: debug
# site.yaml, loaded after base.yaml
intelligence:
model: gpt-5-mini # a scalar: replaced
limits:
subagents: {depth: 3} # an object: merged, so run.steps stays 50
tools:
disabled: [workflow.delete] # a list: replaced whole
observability:
log_level: null # null: cleared, back to the built-in default
agentd -c base.yaml -c site.yaml --effective-config
The result runs gpt-5-mini with 50 steps, a delegation depth of 3,
workflow.delete disabled (and memory.delete enabled again) and the
default log level, and --effective-config credits each value to the file
that set it.
What the instruction may configure
An instruction document can carry configuration of its own, in a
:::!config block, alongside the machinery blocks that declare workflows,
MCP servers, streams and skills (Instruction documents
explains them). That fragment is applied after the document is read, and it
merges underneath the operator's configuration: at every key, a file,
an environment variable or a flag beats the document.
You are the order desk. Every paid order is fulfilled the same day.
:::!config
limits: { run: { steps: 40 }, max_runs: 20 }
context: { keep_last: 16 }
:::
# needs: ./desk.md
agent:
name: order-desk
instruction: { file: ./desk.md }
intelligence:
endpoints: https://api.openai.com/v1/chat/completions
model: gpt-5.1
token: "{{secret:OPENAI_API_KEY}}"
limits:
run: { steps: 20 } # beats the document's 40: the operator's file wins
The boundary is an allow-list: a document configures what the agent is
and never the deployment it runs in. It may write its workflows, streams,
MCP servers and vars; how it thinks and bounds itself (context, memory,
limits, goal); which models it uses and how much (intelligence.models,
the roles except approve, the budget); the shape of its loop
(agent.preflight, agent.wake_on and similar); and its durability class
(store.kind, never the store's location). Everything else is the
operator's: where the instruction comes from and who may sign it, the tool
grant, security, identities, listeners and their principals, model
endpoints, providers and credentials, intelligence.routes, subagent
templates, where state lives and where audit records go, and telemetry
destinations.
Three rules make the boundary hold:
- A path in neither list is refused. The check walks the document's fragment path by path, so a setting nobody has classified belongs to the operator.
- A refusal names the paths. A document that writes
intelligence.endpointsorsecurity.allow_trifectastops the load with exit 2 and a message listing both. - A document can add, never re-point. Its MCP servers join the operator's list, and one that reuses the name of a server the operator declared is refused as a duplicate; MCP Events bindings and grants on a server stay the operator's. A model it declares cannot name a provider, so it can choose which model but never where the conversation goes.
The full allow-list is in the Directive reference.
Note that the short form agent.instruction: <uri> names an MCP resource;
to fetch a document over HTTPS, write agent.instruction: {url: …} (or
--instruction.url).
Validate before any side effect
Loading is free of side effects by design. The document is merged, substituted, typed and validated before agentd opens a store, dials a server, binds a socket or spends a token. A wrong document exits 2 and leaves the world untouched; exit 2 is the code a scheduler should treat as "retrying will not help".
Every check runs, and every error is reported. --validate-config
prints each problem as a config.invalid line and exits 2 (or 0 with a
config.valid line), and a normal start reports the same full list in one
message before exiting 2. One restart fixes the list, not one line per
restart.
Validation covers more than shape. Among the things refused at load:
- an unknown key or flag;
- an endpoint that is not
https://(loopbackhttp://aside); - a reference to an undeclared MCP server or model;
- an inline credential in a file;
- an inbound listener on a non-loopback address with no client authentication;
- a principal rule that matches a client certificate (
san,sub) withouta2a.tls.client_ca(ananyrule needs none); a2a.listen,webhooks.listenora2a.peersin a binary built without thea2afeature;- a model connection, other than the
mock:endpoint, that names no model; - a model connection
auththat cannot send a bearer: a static credential under its own header (set it inintelligence.headersinstead) or a SPIFFE X.509 SVID; - a workflow with no start node or no
finish; - a lethal trifecta — untrusted input, sensitive data and egress reachable by
one agent — unless
security.allow_trifectais set.
What validation cannot see is the world. It does not know whether an MCP
server is up or a model endpoint answers; startup finds that out. It does
read what the configuration points at: an instruction named as a file,
folder, URL or OCI artifact is fetched, because its :::!config can only be
judged once read.
Secrets are references
A configuration file never holds a credential, only a reference to one:
{{secret:NAME}}resolves from the environment variableNAME;{{secret-file:PATH}}resolves by reading the file atPATH, such as a mounted Kubernetes Secret.
A literal value in a file is refused at load for intelligence.token, every
intelligence.providers.<name>.token, a2a.bearer,
security.aauth.enroll_token and an MCP server's oauth.client_secret.
Those may be passed inline from the environment or a flag, where they never
touch disk. A credential-shaped header (Authorization and the like) and an
auth block's token, client_secret and value must be references
wherever they are set.
References are checked by --validate-config and again at startup, in one
scan of the whole document and every inline workflow, so the two cannot
disagree: an unset variable or a missing file is reported, with every other
miss, before the first dial. The value is read at startup, held in memory,
and never written to the settings, a log line or the audit stream; logs
carry header names only. --prompt-missing asks for each missing secret
on the terminal instead, keeping the values in memory only.
Two other substitutions share the document, and they are different things.
${VAR} and ${VAR:-default} expand a process environment variable into any
string at load (an unset variable with no default is an error), except in
the instruction, where ${name} is the document's own parameter.
{{config.NAME}} expands a value from vars: — plain deployment constants,
substituted before a workflow is hashed, never secrets.
Hot reload
A running daemon re-reads its configuration on SIGHUP, or when a watched
file changes (lifecycle.watch_config, which watches each config file's
directory so a Kubernetes ConfigMap swap is seen). A reload is applied
whole or not at all.
The candidate goes through the same load and validation as a start. If any
restart-only path differs, the whole reload is refused with
restart_required, naming the paths. Restart-only paths are the ones built
into the process at startup — among them the instance's name, the store's
kind and location, the listeners' addresses, TLS identities and origins,
the run shape and drain timeout, the document capability grant and trust
pins, telemetry sockets, and all of security. Roll the process to change
them; the Configuration reference marks each path.
Otherwise the reload is staged before anything goes live: new and changed MCP servers are dialed beside the running ones, and the tool registry, workflows, skills catalogue, instruction, principal rules and webhook routes are rebuilt and checked against the servers the reload would run on. If any piece fails, the reload is refused, the connections it dialed are closed, and nothing the daemon was running changes. Only when every piece is staged is it committed, in one step that cannot fail.
The flat process tree does most of the rest. Every turn worker is spawned
from the live settings, so the next unit of work picks up a new model,
instruction or limit. When a reload moves a model, running subagents are
handed it again and switch at their next turn boundary. Live workflow runs
stay pinned to the definition they started with, by hash. Each reload is
logged, audited as a config.reload action, and bumps the configuration
generation in the durable manifest. Every configuration path is classified
as reloadable or restart-only, and a test over the schema fails if one is
not, because an unclassified field is how a reload reports success and
changes nothing.
Next steps
- Configuration reference — every path, its default, its environment variable and flag, and whether it reloads.
- Instruction documents — what a document carries and the trust rule behind the allow-list.
- Operate a running daemon — reloading and draining in practice.
- Troubleshooting — reading
config.invalidand fixing what it names.