Configuration
agentd is configured from the environment, the command line, and an optional
local config file — no network config, ever. The whole
configuration is assembled and validated before any side effect: a bad flag,
a malformed endpoint, a mistyped workflow step, or an unresolvable secret
reference exits 2 in milliseconds, not after an LLM round-trip or an MCP
handshake.
The configuration is one nested config_version: "1" document, with the
sections agent, goal, intelligence, mcp, tools, store, memory,
context, knowledge, search, skills, subagents, services,
workflows, streams, webhooks, limits, lifecycle, a2a, interface,
identity, observability, security, vars. Every path in that schema
is also an env var (limits.max_runs ⇒ AGENTD_LIMITS_MAX_RUNS) and a flag
(--limits.max-runs); a set of short spellings is wired up as aliases
(--instruction, --intelligence, --model, --mcp, --config,
--log-level, …). The authoritative machine-readable schema is
agentd --config-schema; agentd --capabilities prints the effective
configured surface; agentd --validate-config validates without side
effects (exit 2 on error).
0. Editor autocomplete
The schemas are published, so an editor can complete keys, offer the valid
values for every enum, and flag a typo as you type rather than at exit 2:
| Document | URL |
|---|---|
| a config file | https://agentd.dev/schema/config.json (pinned: config-1.json) |
| a standalone workflow file | https://agentd.dev/schema/workflow.json (pinned: workflow-3.json) |
The one-line form, which needs no editor settings and travels with the file:
# yaml-language-server: $schema=https://agentd.dev/schema/config.json
config_version: "1"
agent:
name: my-agent
Or register it once, for every config in a project — VS Code (settings.json,
with the YAML extension):
"yaml.schemas": {
"https://agentd.dev/schema/config.json": ["agentd.yaml", "agentd.yml", ".agentd.yml", "*.agentd.yaml"],
"https://agentd.dev/schema/workflow.json": ["workflows/*.yaml", "workflows/*.yml"]
}
JetBrains IDEs: Settings → Languages & Frameworks → Schemas and DTDs → JSON
Schema Mappings. Neovim with yamlls takes the same yaml.schemas map.
By filename, with no setup at all. VS Code's YAML extension, JetBrains and
yaml-language-server all read the SchemaStore
catalog by default, so a registered schema applies from the filename alone. The
entries to submit are in contrib/schemastore-catalog-entry.json, with the
process and the review gotchas in contrib/README.md. Until that lands, use
the modeline or yaml.schemas above — they work today and override the
catalog anyway.
Inline workflows complete too. A workflows: entry accepts either a
reference (file/uri/url/dir) or a whole definition, and the config
schema folds the workflow document's own properties in — so steps, every node
kind, and each kind's fields complete inside a config file, and kind: teleprot is flagged where you typed it.
Two things the schema deliberately does not do. It will not catch
cross-field rules — concurrency.scope: key needing a key:, a fallback
cycle, a tier name that is not declared — because those need the whole
document; agentd --validate-config reports all of them at once, and that
remains the authority. And it does not know your secrets exist: a
{{secret:NAME}} reference is just a string until startup resolves it.
Pin the versioned URL (config-1.json) when you want a config to keep
validating against the version it was written for; the unversioned alias
follows whatever the current major is.
The files are generated from the binary — the same functions the loader uses —
and CI regenerates and diffs them, so the published schema cannot drift from
the code. Emit them yourself with agentd --config-schema and
agentd --workflow-schema.
1. Precedence
Configuration is resolved in layers, each overriding the previous key by key (a layer only touches the keys it actually sets — an unset env var never clobbers a lower layer):
built-in default < config file < env var < CLI flag
(lowest) (--config; live) (highest)
- built-in default — the compiled-in defaults (see the table below).
- config file(s) — local-only YAML or JSON files (
--config <path>, or-c; the value may attach with=; repeatable;AGENT_CONFIG=a.yaml:b.yaml) carrying verbose structural config (the MCP-server inventory, workflow definitions, A2A peers and principals, the store, limits, model/log knobs, intelligence endpoint list + headers). Live. Several files compose into one document, in order — a later file overrides the earlier ones (§12.2). The merged document slots between default and env, so env and flags still override it. Repeatable list flags ADD to the file's lists (--mcp/--a2a-peer/--workflowappend to what the files declare). Secrets are never stored in a file — only{{secret:NAME}}/{{secret-file:PATH}}references (§12). See §12 for the full file schema. - env var — every setting that has an env equivalent (12-factor). Live.
Every config-file path is an env var too, named after the path
(
limits.run.steps⇒AGENTD_LIMITS_RUN_STEPS; §1.1). - CLI flag — highest precedence; overrides env. Live. Every config-file
path is a flag too (
--limits.run.steps 5; §1.1).
1.1 Config paths — one name, three sources
Every path in the config file's schema is settable from all three sources
with a name derived mechanically from the path, so nothing needs per-field
plumbing (agentd --help prints the full table under CONFIG PATHS):
| source | name for the path limits.run.steps |
|---|---|
| file (YAML or JSON) | limits: { run: { steps: 5 } } |
| env | AGENTD_LIMITS_RUN_STEPS › AGENT_LIMITS_RUN_STEPS › bare LIMITS_RUN_STEPS (first present wins) |
| flag | --limits.run.steps 5 = --limits.run-steps 5 = --limits-run-steps 5 |
Values are typed by the schema: integers/numbers/booleans parse, enums are
checked against their set, a list takes a [a, b] literal or a comma-separated
a, b, an object takes a {k: v} (or JSON) literal, everything else is the
verbatim string. A value that does not type is exit 2 naming the source
(invalid AGENTD_LIMITS_RUN_STEPS: expected an integer, got "many").
Setting a path SETS its value. From env or a --<path> flag, a list or map
path replaces what the files declared (AGENTD_TOOLS_DISABLED=a,b ⇒ exactly
[a, b]; --mcp-servers '[{name: q, endpoint: https://…}]' ⇒ exactly that
list). The named repeatable flags (--mcp, --a2a-peer, --workflow)
add one element. The named scalar aliases in §3 (--max-steps,
AGENT_MAX_STEPS, …) set the same path with a shorter spelling.
Dotted flags reach into objects. --limits.run.steps 5 sets a nested
schema path; --intelligence.headers.x-team ops sets ONE entry of a free-form
map (the key keeps its exact spelling — x-team is not canonicalized) and
merges with the map's other entries; --intelligence-headers '{k: v}' sets the
whole map. Array elements are not addressable by path (--mcp-servers.0.name
is refused with a clear message): set the whole list, or use the named
repeatable flag.
Example — a flag beats the environment:
$ INSTRUCTION='from-env' AGENT_INTELLIGENCE=https://gw.example/v1 \
agentd --instruction 'from-flag'
# effective instruction: "from-flag" (flag wins)
# effective intelligence: https://gw.example/v1 (env, no flag given)
Secrets are env/flag only — never inline in the config file. The
--intelligence-token value is redacted everywhere it could surface
(Debug output prints ***, logs never carry it). The config file may carry
references to secrets ({{secret:NAME}} → an env var, {{secret-file:PATH}}
→ a mounted file) but never an inline credential — a credential-shaped header
with a literal value is rejected at validation (§12).
2. Validate-at-startup → exit 2
Validation runs after all layers merge and before the first side effect
— no MCP connect, no LLM call, no subagent spawn, no socket bind. It is pure-CPU
and sub-millisecond. Diagnostics are collected in one pass: every problem in
the document is reported, not just the first. Each one prints a
{"event":"config.invalid","msg":…} line to stderr and the process exits 2
(EXIT_USAGE, a non-retriable config error for a podFailurePolicy — retrying
a bad document only reproduces it).
| Check | Example diagnostic (exit 2) |
|---|---|
config_version is "1" | config_version must be "1" (got "3") |
every intelligence.endpoints element is https:// (loopback http:// for dev) | intelligence endpoint must be https://host[:port][/path] (got: ftp://nope) / plaintext http:// intelligence is allowed for loopback only (dev); use https:// |
intelligence.swap_policy / dialect / auth are coherent | intelligence.dialect: bedrock requires intelligence.auth.kind = aws (SigV4) |
every mcp.servers[] has a unique non-reserved name, a valid endpoint, and parseable tags | mcp.servers[]: a server has an empty name · mcp.servers[]: duplicate server name 'fs' · mcp server 'a': mcp endpoint must be https://host[:port][/path] (got: ftp://x) |
every server reference resolves (store.mcp.server, knowledge.server, search.server, skills.sources[].server, tools.overrides[].server) | store.mcp.server 'state' is not a declared MCP server |
the chosen store.kind carries its block | store.kind is mcp but store.mcp is not set · store.http needs at least 'get' and 'put' operations · store.file.path is empty — set a directory, or omit the field to use $AGENTD_STATE_DIR / $XDG_STATE_HOME/agentd/state |
a long-lived instance (an a2a.listen/webhooks.listen, a goal, or a loop/schedule/subscribe/signal/event/stream/correlate/a2a/webhook start node) has a durable store — naming no store at all defaults to kind: file (§12.3), so this fires only when a config asks for kind: none outright | store.kind is none but the instance is long-lived … — configure a durable store (store.kind: file | mcp | http), or drop store.kind to get the local file store by default |
every workflow is named, unique, and has exactly one of file | uri | url | steps | workflows['w'] must have exactly one of file | uri | url | steps (dir is a separate entry shape) |
| every inline workflow parses under the workflow node registry — the same parse the runtime runs at startup | workflow "w" step "s": unknown field "every" for kind "loop" (allowed: interval, delay, until, max_iterations, backoff, inputs) |
an a2a.listen: https://… sets a2a.tls.cert + a2a.tls.key, and a non-loopback bind authenticates its clients | a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set · a2a.listen on a non-loopback address needs client auth: a2a.bearer, interface.pairing, or a2a.tls.client_ca (mTLS — then EVERY caller needs a client certificate, bearer-only and paired included) |
interface.enabled has a listener to ride, and pairing has an interface | interface.enabled requires a2a.listen (the interface is served on the A2A listener) |
a webhook node has a listener | a 'webhook' node (start or wait) is used but webhooks.listen is not set — configure webhooks.listen (https://host:port) |
a non-loopback webhooks.listen authenticates every route it serves — symmetric with a2a.listen, since both are inbound listeners that trigger work | webhooks.listen on a non-loopback address needs auth: set webhooks.default_auth (hmac, bearer or header), or give every 'webhook' node its own auth (HMAC recommended) — unauthenticated: w/h |
every a2a.peers[] is uniquely named with an http(s):// endpoint; every a2a.principals[] match names a subject | a2a peer 'p': endpoint must be http(s):// (or unix:///path for a co-located peer) · a2a.principals[0]: match needs one of san | sub | bearer_ref | aauth_agent | any |
lifecycle.exit_code_map remaps only the policy codes | lifecycle.exit_code_map: only the policy codes 3 and 7 are remappable (got key "5") |
lifecycle.watch_config has a file to watch | lifecycle.watch_config requires a config file (--config / AGENTD_CONFIG) |
observability.log_level is a known level; an audit.sink: store has a store | observability.audit.sink includes 'store' but store.kind is none |
| the file layer carries no inline credential | config file: intelligence.token carries an inline credential; use {{secret:NAME}} / {{secret-file:PATH}} (or set it from env/flag) |
no credential-shaped header (intelligence.headers, an MCP server's, a peer's, store.http.headers) has an inline value | intelligence.headers['authorization'] looks like a credential but has an inline value; use {{secret:NAME}} / {{secret-file:PATH}} |
| the root grant is not a lethal trifecta | lethal-trifecta refused: the root grant wires untrusted_input + sensitive + egress into one agent; narrow the tags or set security.allow_trifecta (audited) |
Non-fatal findings come back on the same channel as
{"event":"config.warning","msg":…} and do not change the exit code — a
store.kind: none one-shot (not durable: a crash re-runs it), a
store.kind: memory (state does not survive the process), a store.file block
sitting beside a kind that is not file (dead config, ignored), a non-loopback
webhooks.listen with no webhooks.default_auth and no webhook routes yet
(nothing is reachable, but the next node added would be — an unauthenticated
route is an error, see below), an interface.debug with the
interface off, and an unknown interface.display item.
-h/--help, -V/--version, --capabilities, --config-schema,
--workflow-schema, and --validate-config short-circuit and exit 0
(--validate-config exits 0 on a valid config, 2 if it collected any
diagnostic). None of them need an instruction. An unrecognized argument is a
usage error: unknown argument: <arg> → exit 2; so is an unknown key in a
config file (deny_unknown_fields, §12).
$ agentd --instruction 'x' --intelligence ftp://nope --validate-config
{"event":"config.invalid","msg":"agentd: intelligence endpoint must be https://host[:port][/path] (got: ftp://nope)"}
$ echo $?
2
3. The flag / env table
The flags below are the named aliases: short spellings for the config paths
an operator reaches for most. They are derived verbatim from the binary's
--help, which also prints the complete CONFIG PATHS table — every schema
path as --<path> and AGENTD_<PATH>, whether or not it has an alias here.
The Env column names the alias's short env var. Each is read as
AGENTD_<NAME> › AGENT_<NAME> › bare <NAME> (first present wins) — so
INSTRUCTION below means AGENTD_INSTRUCTION, AGENT_INSTRUCTION, or a bare
INSTRUCTION. The one exception is AGENT_CONFIG, which is written out because
it takes only the two prefixed spellings. A blank cell means the setting has no
short env var — reach it by its path instead (--health-file ⇒
AGENTD_OBSERVABILITY_HEALTH_FILE). Every alias's target path is given so the
file spelling is never in doubt.
3.1 Core
| Flag | Path | Env | Default | Description |
|---|---|---|---|---|
--instruction <VALUE> | agent.instruction | INSTRUCTION | (none) | The standing task/policy — the text, a file path, a folder, or a URI (oci://, mcp://, instruction://, https://). agentd tells them apart; see §5a.1. |
--instruction.text <TEXT> | agent.instruction.text | — | — | The instruction itself, never read as a path or URI. |
--instruction.file <PATH> | agent.instruction.file | — | — | A local file (e.g. a ConfigMap/Secret projection); watched when lifecycle.watch_config is on. |
--instruction.oci <REF> | agent.instruction.oci | — | — | An OCI artifact — ghcr.io/acme/agent:v3 or …@sha256:… (the oci:// is implied). |
--instruction.dir <DIR> | agent.instruction.dir | — | — | A folder of documents, combined into one instruction. §5a.1a. |
--instruction.glob <GLOBS> | agent.instruction.dir.glob | — | *.md,*.markdown,*.txt,*.instruction | Which files under the folder (comma-separated; ** recurses). |
--instruction.order <name|date> | agent.instruction.dir.order | — | name | The order the folder's documents combine in. |
--instruction.url <URL> | agent.instruction.url | — | — | An https:// document, fetched at load. |
--instruction.mcp <URI> | agent.instruction.mcp | — | — | A resource a declared MCP server serves (read + subscribed). |
--instruction.refresh <auto|off|DUR> | agent.instruction.refresh | — | auto | How often to re-read it; auto picks the mechanism that fits the source. §5a.2. |
--instruction.unavailable <POLICY> | agent.instruction.unavailable | — | auto | What to do when the source stops answering: auto, keep, freeze, drain, exit. §5a.3. |
--prompt <VALUE> | agent.prompt | PROMPT | (none) | A one-shot task: delivered as a message into the agent's root context at startup — a root turn with the full tool surface, not a generated workflow step — while instruction stays the standing policy. Naming a prompt also suppresses the --instruction sugar workflow. Classified exactly as --instruction is, so a path names the file. |
--prompt.text <TEXT> | agent.prompt.text | — | — | The task itself, never read as a path or URI. |
--prompt.file <PATH> | agent.prompt.file | — | — | Read the task from a local file. |
--prompt.dir <DIR> | agent.prompt.dir | — | — | A folder of documents, combined into one task (--prompt.glob → agent.prompt.dir.glob, --prompt.order → …dir.order). |
--prompt.url <URL> | agent.prompt.url | — | — | An https:// document, fetched at load. |
--prompt.oci <REF> | agent.prompt.oci | — | — | An OCI artifact (needs --features oci). |
--intelligence <LIST> | intelligence.endpoints | INTELLIGENCE | (none) | Ordered, comma-separated LLM endpoint list for failover. Each element is https://host[:port][/path] (or a loopback http:// for a same-host dev gateway) — see §4. |
-c, --config <PATH> | — | AGENT_CONFIG | (none) | Load a declarative config file — YAML or JSON (§12). Repeatable; the = form works too. |
3.2 Intelligence
| Flag | Path | Env | Default | Description |
|---|---|---|---|---|
--intelligence-token <T> | intelligence.token | INTELLIGENCE_TOKEN | (none) | Bearer/API key for endpoint 1. Never logged; redacted as ***. From a file it must be a {{secret:…}} reference (§12). |
--intelligence-token-file <PATH> | intelligence.token_file | INTELLIGENCE_TOKEN_FILE | (none) | Read endpoint 1's token from a mounted file (rotation-friendly). An inline token wins over it (and setting both is a warning). |
| — | — | AGENTD_INTELLIGENCE_TOKEN_<N> / …_<N>_FILE | (none) | Per-endpoint credential for endpoint N (1-indexed; endpoint 1 uses the bare names above, endpoint 2 → _2/_2_FILE, etc.). Env-only. |
--model <NAME> | intelligence.model | MODEL | (none) | Model id passed to the endpoint. Reloadable (§11). |
--model-swap <P> | intelligence.swap_policy | MODEL_SWAP | finish-on-old | What an in-flight run does when a reload changes model: finish-on-old (the in-flight turn finishes on the old model, the next turn uses the new one) | restart-turn (the in-flight turn is re-run on the new model from the same pre-turn state). An endpoint repoint with the model unchanged is always finish-on-old regardless — the conversation is identical on either endpoint, so there is nothing to re-run. |
--tls-ca <PATH> | security.tls_ca | TLS_CA | (none — bundled webpki roots only) | Extra PEM CA certificate(s) trusted for every outbound https:// dial (intelligence, MCP servers, A2A peers, OAuth token endpoints), added to the bundled webpki roots — the private/in-cluster PKI anchor. Public material (a CA cert path, never a key). Read at startup — a missing/unreadable/non-CA PEM is security.tls_ca <path>: … → exit 2 before the first dial (the path is not checked by --validate-config, which never touches the filesystem for credentials). Installed process-wide and inherited by every subagent via the spawn payload. Restart-only. Needs the tls build feature. |
The endpoint's auth block (intelligence.auth.* — OAuth 2.1, AWS SigV4,
SPIFFE) and intelligence.dialect / intelligence.headers /
intelligence.budget have no short aliases; set them by path or in the file. See
authentication.md and intelligence.md.
3.3 Tools / MCP / delegation
| Flag | Path | Env | Default | Description |
|---|---|---|---|---|
--mcp name=<endpoint> | mcp.servers (adds one) | — | (none) | Declare a remote MCP server, reached over Streamable HTTP — name=https://host[:port][/path] (or a loopback http:// for dev). agentd spawns no local process. Repeatable. See §5. Reloadable (§11). |
--mcp-tags name=tag,tag | mcp.servers[].tags | — | (none) | Capability tags for the Rule-of-Two check: untrusted_input|sensitive|egress. Attaches to a declared server (order-independent); an unknown name is exit 2. Repeatable. |
--listen <TARGET> | a2a.listen | SERVE_MCP | (off) | Arm the A2A listener — the daemon's external channel and operator control: https://host:port (mTLS/bearer auth) or a loopback http://host:port (dev). --serve-mcp is the same alias. Needs --features a2a. |
--serve-cert / --serve-key / --serve-client-ca | a2a.tls.cert / .key / .client_ca | — | (none) | The listener's server certificate, private key, and client-CA bundle for mTLS. An https:// listen without cert+key is exit 2. |
--serve-bearer <T> | a2a.bearer | SERVE_BEARER | (none) | Static bearer token accepted by the listener. From a file it must be a {{secret:…}} reference. |
--a2a-peer name=<ENDPOINT> | a2a.peers (adds one) | — | (none) | Declare a remote A2A delegation peer: https://host[:port] (or a loopback http://, or unix:///path for a co-located instance). Repeatable. Needs --features a2a. |
--workflow <FILE> | workflows (adds one) | — | (none) | Append a workflow definition to workflows: as {name: <file stem>, file: <path>} — its start node is the trigger. Repeatable; the same as an inline workflows: entry. See workflows.md. |
--allow-trifecta | security.allow_trifecta | ALLOW_TRIFECTA | false | Permit all three lethal-trifecta legs in one agent: the startup refusal is downgraded to a loud, audited warning rather than dropped. |
--env <FILE> | (process env) | — | (none) | Load a dotenv file into this process's environment before anything reads it — ${VAR} expansion, {{secret:NAME}} resolution, subagent inheritance all see it. Repeatable: later files win; the real environment always wins over any file. KEY=VALUE, export prefix ok, # comments, '…' literal, "…" with \n-style escapes, no $VAR interpolation inside the file. A malformed line refuses startup naming file:line. |
--fresh | (process intent) | — | (none) | Start a NEW durable-store generation instead of resuming; the previous generation stays on the store, so nothing is deleted by starting clean. |
--prompt-missing | (process intent) | — | (none) | Ask interactively on /dev/tty (echo off) for each {{secret:NAME}} the startup preflight finds missing. Values live in process memory only; a restart re-asks. Refused without a controlling terminal (§12.3). |
Durable runs resume automatically from the store on restart; a workflow's
own resume_policy (force to always restart it) is the per-graph control.
3.4 Limits & budgets
| Flag | Path | Env | Default | Description |
|---|---|---|---|---|
--max-steps <N> | limits.run.steps | MAX_STEPS | 500 | Per-run step cap. Reloadable (§11). |
--max-tokens <N> | limits.run.tokens | MAX_TOKENS | 2000000 | Token budget for a single run. Reloadable (§11). |
--deadline <dur> | limits.run.deadline | DEADLINE | 3600s | Per-run wall-clock deadline (duration syntax, §7). Reloadable (§11). |
--max-depth <N> | limits.subagents.depth | — | 3 | Subagent tree depth cap — how many levels of children a tree may nest. |
--budget-tokens-lifetime <N> | intelligence.budget.lifetime_tokens | BUDGET_TOKENS | 0 (unbounded) | Per-instance cumulative token cap across all runs. See §3.4a. |
| — | intelligence.budget.lifetime_exhausted | — | drain | What the PROCESS does once that cap is spent: drain (finish live work, exit 0), refuse (stay up refusing), exit (stop now, exit 7). See §3.4a. |
--budget-exit-code <N> | lifecycle.exit_code_map | — | (none) | Remap the policy exit codes 3 and 7 to N (0..=255) — e.g. exit 0 so a budget stop is not a pod failure. |
limits.max_runs (concurrent runs, default 8), limits.step_timeout,
limits.inline_max_bytes, and limits.subagents.{breadth,total,rate} have no
short alias; set them by path.
3.4a The lifetime token budget (--budget-tokens-lifetime)
--max-tokens boxes a single run; --budget-tokens-lifetime bounds the whole
instance — the cumulative tokens across every run the process performs. It
exists so a long-lived agent on a path with no metering gateway (e.g. an
AAuth direct dial) still stays bounded. 0 (the default) is unbounded.
-
A job is that single run, so the effective per-run cap is
min(limits.run.tokens, intelligence.budget.lifetime_tokens); exhaustion is the ordinaryEXIT_BUDGET(7)path (remappable with--budget-exit-code). -
A daemon meters cumulative usage; once the cap is reached, what happens to the instance is
intelligence.budget.lifetime_exhausted. The unit that tripped it always fails — a conversation turn is dropped (loggedbudget.refused), a workflow step fails withlifetime token budget exhausted (<scope>)— and then:Policy The instance Use when drain(default)finishes live work, then exits 0an orchestrator restarts it with a fresh window, which is what a lifetime budget is usually for refusestays up, refusing every admission you would rather inspect a stopped instance than lose it. The pre-1.15 behaviour exitstops now, exit 7overrunning the ceiling is a failure to notice, not a lifecycle The transition is one log line —
budget.lifetime_exhaustedwith the policy that applied — emitted once, not once per refused admission. This is the same vocabularyagent.instruction.unavailableuses for the other "something this agent depends on ran out" question, minus the two words that cannot apply here: you cannotkeepspending, andfreezeis indistinguishable fromrefusewhen live work needs tokens too. -
Observability: the gauge
agent_budget_tokens_remainingtracks the balance continuously — the alerting/scaling hook is a threshold rule on that gauge, since the runtime fires nothing of its own as the cap APPROACHES (thebudget.lifetime_exhaustedline above comes after the fact, not before it). On the fleet, the budget is per-member (each pod carries its own instance budget); an aggregate fleet cap remains a gateway concern.
The lifetime ceiling is the blunt end of intelligence.budget, which also takes
rolling windows ({per: hour, tokens: 2000000}), an on_exhausted tactic
(wait|slow|degrade|refuse|fail), a reserve, and a scope.
on_exhausted governs a WINDOW, where "wait" is a real answer because the
window resets; lifetime_exhausted governs the ceiling that never resets, which
is why they are two settings and not one.
agent.conversation_budget is the same shape applied per conversation.
3.5 Runtime / observability / security
| Flag | Path | Env | Default | Description |
|---|---|---|---|---|
--run-id <ID> | lifecycle.run_id | RUN_ID | (auto) | Idempotency key (§8). Default: a freshly minted ULID. |
--drain-timeout <dur> | lifecycle.drain_timeout | DRAIN_TIMEOUT | 25s | Graceful drain budget. Keep < pod terminationGracePeriodSeconds, or the kubelet's SIGKILL lands mid-drain. |
--log-level <L> | observability.log_level | LOG_LEVEL | info | trace|debug|info|warn|error. Reloadable (§11). |
--log-content | observability.log_content | LOG_CONTENT | false | Log tool args/results, not just lengths. Off by default (content-capture-off), because arguments and results routinely carry data the log is not the right home for; propagates to children. Reloadable (§11). |
--health-file <PATH> | observability.health_file | — | (none) | Liveness heartbeat file, rewritten every 10s — the exec-probe target for images with no HTTP surface. |
--metrics-addr <ADDR> | observability.metrics_addr | METRICS_ADDR | (off) | Serve /metrics+/healthz+/readyz on a TCP addr — host:port, or :port for all IPv4 interfaces (read-only; restrict via firewall/NetworkPolicy if exposed). Needs --features metrics. |
--traceparent <W3C> | observability.traceparent | TRACEPARENT | (none) | Continue an upstream W3C trace; else a trace id is minted from the run id. |
--events-ring <N> | observability.events_ring | — | 1024 | Capacity of the in-memory log ring the interface's debug feed tails. Installed only when interface.enabled and interface.debug are on, so an instance with no debug UI pays nothing for it. |
--report-file <PATH> | observability.report_file | — | (off) | Path for a run-outcome report file. Accepted by the schema; the runtime does not write it — the terminal outcome is the proc.exit event and the A2A task artifact. |
--cgroup <auto|PATH> | security.cgroup.spec | — | (off) | cgroup-v2 parent for spawned children (turn workers + subagents), each placed in its own leaf for atomic cgroup.kill teardown: auto (derive <own-cgroup>/agent) or an absolute path under /sys/fs/cgroup. Best-effort — disabled if not writable. Linux only. |
--cgroup-memory-max <SIZE> | security.cgroup.memory_max | — | (none) | Per-child memory.max: max or a size (512M/2G/bytes). Needs a parent that can delegate the memory controller. |
--cgroup-pids-max <N> | security.cgroup.pids_max | — | (none) | Per-child pids.max: max or a count. Counts threads — set it generously. Needs delegation. |
--aauth-provider, --aauth-key-file, --aauth-enroll-token, --aauth-enroll-assertion-file, --aauth-person-server | security.aauth.* | — | (none) | Agent-identity signing for AAuth-protected servers. Needs --features aauth; see aauth.md. |
--login <target> | — | — | — | Complete an interactive OAuth device login for an endpoint (e.g. mcp:<name>) and cache the token; exits. Needs --features oauth; see authentication.md. |
--logout <target> | — | — | — | Evict a cached credential; exits. |
--capabilities | — | — | — | Print the capabilities manifest (JSON) and exit 0 — the side-effect-free admission probe. |
-h, --help | — | — | — | Print help (including the full CONFIG PATHS table) and exit 0. |
-V, --version | — | — | — | Print version and exit 0. |
security.exec.* (the guarded local command runner, default-OFF at both build
and run time) is documented in security.md §11.
3.6 Config file & hot reload
| Flag | Path | Env | Default | Description |
|---|---|---|---|---|
-c, --config <PATH> | — | AGENT_CONFIG | (none) | Load a declarative config file — YAML (.yaml/.yml) or JSON (.json/.jsonc; other extensions are sniffed) (§12). The lowest non-default precedence layer. |
--validate-config | — | — | — | Load + validate (files + env + flags), print the admission verdict (one config.valid line, or one config.invalid line per diagnostic — all collected in one pass), exit 0/2. Side-effect-free. |
--effective-config | — | — | — | Print the assembled config and where each setting came from (§10a), to stdout as one JSON object, and exit 0. Runs on an invalid config too — that is when it is most wanted — reporting the errors on stderr beside it. Credential-shaped values are redacted. Side-effect-free. |
--config-schema | — | — | — | Print the settings JSON Schema (Draft 2020-12) to stdout and exit 0. Side-effect-free. |
--workflow-schema | — | — | — | Print the workflow JSON Schema + node registry to stdout and exit 0. |
--watch-config | lifecycle.watch_config | WATCH_CONFIG | false | Watch each config file's parent directory via inotify and reload on change (the same reload SIGHUP triggers). Needs a --config/AGENT_CONFIG file (validated, exit 2) and the config-watch build feature — without the feature the watch is simply not installed. See §11. |
Hot reload itself (the hot-reload feature) is triggered by SIGHUP — there
is no flag for it (§9, §11).
3.7 Subcommands
agentd tui and agentd ui run the daemon with a display client attached:
the terminal UI (--inline for in-place instead of fullscreen) or
the web UI opened in a browser. Both set interface.enabled: true for you, and
the client exits with the daemon. To attach detached instead, run agentd -c …
and point agentd-tui --endpoint <url> at it. See interface.md.
Not wired. There is no
--log-format/AGENT_LOG_FORMAT(the log surface is JSON lines, always), no--health-addr/AGENT_HEALTH_ADDR(/healthzis served by themetricsfeature on--metrics-addr), noRUST_LOG, and no--pod-grace/AGENT_POD_GRACE_SECONDS. Only the tables above and theCONFIG PATHStable inagentd --helpare real.
4. Intelligence endpoints — schemes & failover
intelligence.endpoints is an ordered endpoint list — a YAML sequence, or
the comma-separated string --intelligence takes. A single element is the
common case; multiple elements give sticky-primary failover — agentd
prefers the first healthy endpoint and falls back on a circuit-breaker trip.
Each element is selected by URI scheme:
| Scheme | Form | Use |
|---|---|---|
https: | https://api.example.com/v1 | Remote HTTPS endpoint (the default; tls feature). Pair with a token. |
http: | http://127.0.0.1:8080 | Loopback only — a same-host dev gateway. Any other http:// host is rejected. |
mock: | mock:final, mock:file:play.json | Offline dev: the built-in mock LLM, spawned in-process, dialled over loopback — a whole agent runs with no key, no network, no second terminal. Debug builds always carry it; a release binary needs --features internal-mocks. Scripts: final (answer immediately), read, schedule, file:<playbook.json> (scripted turns). |
Every element's scheme is validated at startup; a non-https/non-loopback-http
scheme on any element (e.g. ftp://…, or http:// to a remote host) is exit
2. An https: endpoint on a --no-default-features build (no tls) passes the
startup scheme check and is surfaced by the client as Unsupported at dial time —
so a --validate-config/--capabilities probe still passes. An empty endpoint
list is not a config error: it fails at the first turn, with exit 4
(intelligence unavailable).
Per-endpoint credentials. Endpoint 1 uses --intelligence-token /
AGENT_INTELLIGENCE_TOKEN (or …_FILE). Later endpoints are 1-indexed by env
only: endpoint 2 → AGENT_INTELLIGENCE_TOKEN_2 (or AGENT_INTELLIGENCE_TOKEN_2_FILE),
endpoint 3 → _3, and so on. The inline value wins over the file; an absent
token is legal (a public/unauthenticated gateway). A per-endpoint token file is
read when that endpoint is resolved, so an unreadable path surfaces there, not at
config validation.
# Single endpoint
$ agentd --instruction 'summarize the queue' \
--intelligence https://api.example.com/v1 \
--intelligence-token "$LLM_KEY" --model my-model
# Two endpoints with per-endpoint creds (primary + fallback)
$ AGENT_INTELLIGENCE_TOKEN="$PRIMARY_KEY" \
AGENT_INTELLIGENCE_TOKEN_2_FILE=/var/run/secrets/fallback-token \
agentd --instruction 'summarize the queue' \
--intelligence 'https://primary.internal/v1,https://fallback.internal/v1' \
--model my-model
The endpoint list and the model/model-swap knobs are file-settable and
reloadable — a ConfigMap repoint is a hot-swap, not a restart (§11, §12).
5. Declaring MCP servers — --mcp name=<endpoint>
Alongside its own internal tools (memory, artifacts, subagents, workflow control
— agentd --capabilities lists them), every task tool comes from an MCP
server. agentd runs no local code for them: declare each server with --mcp,
repeatable, and each names a remote MCP endpoint reached over Streamable
HTTP:
--mcp <name>=<endpoint>
The spec is split once on =: the left side is the server name, the right
side is the endpoint — https://host[:port][/path] (or a loopback http://
for dev). agentd spawns no subprocess; it dials the endpoint.
$ agentd --instruction 'tidy /data' \
--intelligence https://gw.example/v1 \
--mcp fs=https://mcp-fs.internal/mcp \
--mcp git=https://mcp-git.internal/mcp
Per-server auth/framing headers (e.g. Authorization: Bearer {{secret:…}}) are
declared secret-free in the config file's mcp.servers[].headers and resolved at
connect time (§12), never inlined in the spec or logged. Richer per-server
options — auth, oauth, aauth, ns, timeout, tags — are file/path only.
A spec without = fails with --mcp: want name=endpoint (got: …); an empty name
is mcp.servers[]: a server has an empty name; a repeated name is
mcp.servers[]: duplicate server name '<name>'; the name code is reserved for
code-registered tools; and a non-https/non-loopback-http endpoint is rejected
at startup. All exit 2.
The system prompt — context.template:
The system prompt is data plus a template. The runtime exposes what it
knows — instance, instruction, workflows, services, streams,
templates, skills, peers, signals, memory, tools.internal — and a
small language renders it:
{{ expr }} interpolate
{{#if expr}} … {{else}} … {{/if}} emptiness counts as false
{{#each expr}} … {{/each}} `this` is the element, `@index` its position
{{! comment }}
Expressions resolve as a path first, CEL second. {{instance}} and
{{#each services}} are bare lookups that work in any build; anything more
(take(services, 16), size(peers) > 0) is CEL and needs --features cel,
refused at config load on a build without it. Two helpers exist because CEL
lacks them: take(list, n) (no slicing in CEL) and join(list, sep).
The built-in default deliberately uses bare paths only, so it renders on
every build. That is why the data carries both a list and its joined text
(tags / tags_text, params / params_text) and caps lists at 16 (peers
at 24): the default needs no expressions, and CEL is there when you want
different caps or filters.
context:
template: |
You are {{instance}}. {{#if egress_closed}}Egress is closed.{{/if}}
## Instruction
{{instruction}}
{{#if services}}
## Services
{{#each take(services, 16)}}- {{this.name}}{{#if this.tags}} [{{join(this.tags, ", ")}}]{{/if}}
{{/each}}{{/if}}
templates:
minimal: "You are {{instance}}: {{instruction}}" # a node picks this
Start from the built-in rather than from scratch — agentd --context-template prints it, and it is written in this same language.
Order it stable-to-volatile. Providers cache on the literal prefix of a
request, so a section that changes between turns invalidates the cache for
everything after it. The built-in default puts persona and instruction first,
then configuration-derived sections (workflows, services, streams, subagent
templates), then live state (peers, parked signals, memory keys). A template
that leads with {{#each signals.waiting}} works fine and quietly misses the
cache on most turns.
Malformed blocks, unknown block tags and references to names the runtime does
not export refuse startup. A template that never mentions {{instruction}}
is legal but warns on every boot, because an agent that silently lost its
standing policy still looks like a working agent. A step selects an
alternate with context: {template: minimal, seed: [...]}.
Compaction has the same treatment at its own scale: context.summarize.prompt
replaces the summarizer's guidance and context.summarize.model runs it on a
cheaper model. The summary's JSON schema is not overridable — it is parsed
back into the context, so a prompt asking for another shape produces a refusal
rather than a nicer summary.
The service catalog — services:
For deployments past a handful of servers, the catalog names the external
services the deployment may use once, and mcp.servers entries reference
them:
services:
billing:
endpoint: https://billing.internal/mcp
auth: {kind: static, token: "{{secret:BILLING_MCP}}"}
tags: {"*": [sensitive]} # authoritative — a floor, never a suggestion
allow: [charge_lookup, invoice_*] # the CEILING any consumer may get
rate: "60/1m" # this instance's pacing toward the service
mcp:
servers:
- {name: money, service: billing, allow: [charge_lookup]} # reference + narrow
security:
egress: closed # only catalogued endpoints may be dialed
Three rules carry it. Consumers reference, never restate — endpoint,
auth and headers on a referencing entry are refused; the effective tool
surface is the intersection with the ceiling (a widening allow pattern is a
startup error), excludes union, and an absent consumer allow inherits the
ceiling itself. Catalog tags are a floor, unconditionally — any server
whose endpoint matches an entry (referencing or inline, open or
closed) gets the entry's tags unioned in before the trifecta gate runs, so
under-tagging cannot launder a sensitive endpoint. security.egress: closed makes the catalog enforceable — a configured MCP server, the
machinery a subagent template brings with it, or a caller-registered A2A push
target whose URL matches no entry is refused; URL matching is scheme +
authority + path prefix
on segment boundaries, and prefix-comparable entries are themselves a
validation error. --validate-config prints each consumer's effective
endpoint, admission lists and tags, so review reads the outcome.
A referencing server's credential caches under service:<entry> — one
agentd login service:billing (or login mcp:money, which canonicalizes)
serves every consumer of the entry. Multi-instance fleets share one catalog
by merge order: agentd -c services.yaml -c desk.yaml —
examples/startup/services.yaml
is the reference deployment.
Entries carry a kind: — mcp (default), intelligence, peer, http —
and matching is kind-filtered, so one host may serve several kinds. closed
covers all four surfaces: MCP dials, intelligence.endpoints (mock:
excepted), a2a.peers (which take service: references exactly like MCP
servers, inheriting endpoint/auth/headers — also the way peers get agentd login), the http step (literal URLs at load, templated at execution, plus
a kind: http entry's methods: [GET, POST] ceiling in either mode), the
HTTP store, workflow-reference URLs, and A2A push targets. The one surface
deliberately outside: observability.otel.endpoint (validation says so).
Two more per-entry knobs: rate: paces every consumer's calls in each
process — the reactor's steps and the worker/subagent processes' own
in-loop calls alike (a dry bucket is a tool/step failure a retry absorbs;
rate changes take a restart) — and breaker: {failures, cooldown} is the
default breaker POLICY for mcp.tool steps against the entry, while the
breaker's open/closed state stays per step.
5a. The instruction — where it comes from, and what happens when it moves
An agent's instruction is one setting with six sources and three behaviours.
agent.instruction carries all of it.
The same shape — the same classification, the same source keys, the same code —
also carries agent.prompt
and a subagent template's instruction.
5a.1 Naming the source
The short form is a value, and agentd works out what kind it is:
agent:
instruction: "You are the order desk. Every paid order is fulfilled."
# or ./agent.md — a path
# or ./instructions/ — a FOLDER of documents, combined into one
# or oci://ghcr.io/acme/agent:v3
# or instruction://ins_1@stable — a resource an MCP server serves
# or https://docs.example/agent.md
The classification is deliberately narrow, because reading prose as a path would be the worst outcome available:
| The value | Read as |
|---|---|
| contains a newline | text — no path has one, and this is what instruction: | produces |
scheme://… | that URI — oci:// an artifact, https:// a document, anything else a served resource |
file://… | a file, said outright |
no whitespace and a document extension (.md, .markdown, .txt, .instruction) | a file |
no whitespace and path-shaped (/, ./, ../, ~/), ending in / or naming a folder that exists | a directory |
| no whitespace and path-shaped otherwise | a file |
| anything else | text |
So "Summarize the file report.md" is text (it has spaces), ./agent.md is a
file, and a named file that is missing is a refusal that names both
readings rather than an agent silently instructed with a path.
The long form names the source explicitly and is the only place the other settings live:
agent:
instruction:
oci: "ghcr.io/acme/agent:latest" # exactly one of: text file dir oci url mcp
refresh: 60s
unavailable: drain
decrypt: { keys: ["/etc/keys/agent.key"] }
Under the long form a source key means what it says: a value under file: is
a path even if it reads like prose, and a value under text: is the
instruction even if it looks like a path.
Naming two sources is a refusal — there is no right guess. Naming none
is also a refusal: writing instruction: is saying the agent has one, and
ending up without it because a source key was forgotten is how an agent
silently becomes an agent with no instructions, which is not an agent. To have
none deliberately, omit instruction entirely — a workflow-only or --prompt
agent is a different shape, not a broken one.
mcp: takes either the resource URI or the pair, because the URI-only way to
name a server nests one URI inside another:
mcp: "instruction://ins_1@stable" # any server that serves it
mcp: { server: gateway, resource: "instruction://ins_1@stable" } # that server
The two spellings compose on the command line — --instruction ./agent.md --instruction.refresh 30s keeps both, in either order.
5a.1a A folder of documents — dir:
A policy that outgrew one file does not need a build step. dir: combines
every document a folder matches into one instruction:
agent:
instruction:
dir: ./instructions # or just: instruction: ./instructions/
# …and when the defaults are not what you want, the folder carries its own
# settings, because `glob` and `order` qualify the folder and nothing else:
agent:
instruction:
dir:
path: ./instructions
glob: "*.md" # default: *.md,*.markdown,*.txt,*.instruction
order: name # name (default) | date
Writing glob: or order: beside dir: rather than inside it is a refusal
that says where they live — a setting with nothing to qualify should not be
sayable, which is why they are nested rather than validated.
order: namesorts by path, which is what a10-,20-,30-naming convention exists to exploit — and it is stable across machines, unlike the order a directory listing happens to come back in.order: datesorts by modification time, oldest first, so the newest material reads last.- A
**in the glob recurses; a bare*.mddoes not — the same rule every other tool that takes a glob uses. - Documents are joined with one blank line between them, each trimmed of trailing blank lines, so the combination does not depend on how a file happens to end.
- Front matter belongs to a document, and the combination is one document: the first file's front matter is kept, and a later file's is dropped with a warning rather than left to read as prose in the middle of the text.
- A folder that matches nothing is a refusal, not an empty instruction.
This is the same folder source a workflow entry takes, running the same code, so a folder of documents behaves identically wherever it appears.
With lifecycle.watch_config on, the folder is watched as well as the
files in it — for a folder, the change an operator makes most often is dropping
a new document in, which no watch on the files already there can see.
5a.1b The same shape for agent.prompt and subagent templates
A one-shot task and a subagent template's definition are documents too, and they are as likely to live in a file as to be typed at a terminal:
agent:
prompt: ./tasks/close-the-books.md # classified exactly as `instruction` is
# or prompt: { file: ./task.md }
# or prompt: { dir: { path: ./tasks, order: date } }
# or prompt: { text: "./this-is-literally-the-task.md" }
subagents:
templates:
researcher:
instruction: { dir: { path: ./templates/researcher, glob: "*.md" } }
Both take the load-time sources — text, file, dir (carrying its own
glob and order), url, oci — and refuse the same way instruction
does: naming two sources, or putting glob:/order: outside the dir: they
qualify, is an error at load. mcp: is
instruction-only: an MCP resource is read and subscribed by the runtime's
client, which does not exist yet at config load, so a child that needs one
gets its own agent.instruction.
5a.2 Refresh — auto, off, or a duration
auto (the default) picks the mechanism that fits the source, because polling
is only ever right for one of them:
| Source | auto does |
|---|---|
| a file | watches it (inotify) when lifecycle.watch_config is on — instant, and no polling |
| a directory | watches the folder and its documents, likewise — a new file appearing counts |
oci://…@sha256: | nothing — a digest pin is immutable, so re-reading can only return what it already returned |
oci://…:tag | polls every 5m — a mutable tag is the one case polling is for |
mcp://, instruction:// | the server's resources/updated notification, polling every 5m as a fallback |
| text | nothing to re-read |
A duration (refresh: 30s) overrides it; off disables re-reading entirely.
A change to refresh takes effect on reload without a restart.
What a refresh applies. A re-read updates the DELIVERED TEXT — what the
model reads. The machinery a document declares (:::!workflow, :::!mcp, …)
folds into configuration at LOAD, so machinery changes apply on a reload or
restart, not on a poll. A SIGHUP (or a watched config file changing) re-reads
the source and re-folds its machinery, which is the full update path.
agent.instruction.trust[].freshness is a different thing that looks similar: it
is the §7.7 revocation deadline for a signed document — how long an
authorization may go unconfirmed before the agent stops acting on it. When
both are set the tighter one wins, since a poll slower than the deadline would
let an authorization expire between checks.
5a.3 When the source stops answering
At startup an unreachable source is always fatal (exit 6), and
deliberately so: there is no previous instruction to fall back to, and an
agent without its instructions is not an agent — it is a process that will do
something other than what it was told. No policy softens this; unavailable
governs only what happens AFTER a successful start:
| Policy | Behaviour | Use when |
|---|---|---|
keep | carry on with the last good instruction | availability matters more than currency; the agent already holds a good copy |
freeze | serve live work, refuse NEW work | the §7.7 posture — stop taking on what you cannot justify, without abandoning what you accepted |
drain | finish live work, then exit 0 | an orchestrator will restart you, and startup re-reads the source — often right in Kubernetes |
exit | stop now, non-zero | running on a stale instruction is worse than not running |
auto (default) | freeze when the source is trust-pinned (a publisher under agent.instruction.trust), keep otherwise | a stale authorization is a security question; an unreachable unsigned artifact is usually a blip |
"Stops answering" includes answering with something the pin refuses: a re-pull
whose signature does not verify is a failed re-read, and takes the same policy
path. That is the point of auto — a registry that starts serving unsigned
documents is a security event, not a blip.
Every outcome is one log line — instruction.unavailable with the policy that
applied and whether the source was trust-pinned — so a frozen or draining
agent is never a mystery.
5a.3a What lifecycle.watch_config actually watches
With watch_config on, agentd watches every document a reload re-reads,
and nothing else:
| Watched | Why |
|---|---|
| each config file that was loaded | the original case |
agent.instruction's file | the document an operator edits most often |
agent.instruction's folder and the documents in it | a document appearing is a change no file watch can see |
each workflow entry's file:, and each dir: on its glob | workflows.* is reloadable; the definitions live in those files |
skills.dir, and every skill file loaded from it | skills are documents, and the reload rebuilds the catalogue from the folder — the per-file watches catch a <name>/SKILL.md edit, which a watch on the parent folder never sees |
A reload triggered this way re-reads those documents even when the config values around them are byte-identical — an edited workflow file whose entry never changed used to reload successfully and change nothing.
Deliberately not watched:
intelligence.token_fileneeds no watch: it is re-read at every dial, so a rotated Kubernetes projected token or SPIFFE JWT-SVID is picked up by the next request with no reload at all.- TLS material —
a2a.tls,webhooks.tls,security.*— is restart-only. A watch there would fire a reload that cannot apply the rotation and would report success anyway, which is precisely the failure this project refuses to ship. Rotate a certificate by restarting the process.
5a.3b Trust — who may sign this document
agent.instruction.trust pins the publisher and keys a SIGNED instruction must
carry. It lives here rather than at the top level because it is not a separate
subject: its freshness is the same clock refresh sets, and being pinned is
what makes unavailable: auto mean freeze rather than keep.
agent:
instruction:
mcp: "instruction://ins_42@stable"
unenforceable: warn # warn (default) | refuse | ignore
trust:
- uri: "instruction://ins_42"
publisher: "https://instruction.md/pub/acme"
author_keys: [/etc/keys/acme-author.pem]
delivery_keys: [/etc/keys/delivery.pem]
reader: "agent://ops-1" # enables the delivery `aud` check
max_capabilities: [material]
freshness: 15m
It is not a source. file, dir, url, oci and mcp above say where
the document comes from; trust says who may have signed what they serve. (It
was instruction_sources at the top level through v1.12.0; the old spelling is
refused by name.)
Where it applies — everywhere. The signature travels INSIDE the document,
as a front-matter signature: line, so the same signed bytes verify however
they arrived: a file, a folder entry, an https:// fetch, an OCI artifact, or
a registry read. Verification happens once the bytes are in hand (after
decryption, before anything interprets them), against the publisher and keys
pinned here.
With a publisher pinned, an unsigned document is refused — as is one signed
by anybody else, or one whose doc id no pin covers. Matching on the
document's own id alone would let an attacker dodge every pin by deleting a
line.
A folder is verified per file, before the documents are combined: the
combination carries no single signature, because a later document's front
matter (its signature: included) is dropped when they join.
And it keeps applying. The §7.7 freshness watch re-pulls a mutable source on a cadence; every re-pull is verified against the same pins before its bytes become the running instruction, and the capabilities it attests cap the grant again — a re-pulled document that attests fewer families gets fewer. A refusal keeps the instruction the agent already holds rather than adopting an unverified one. Verifying only at startup would have left open the exact case a pin is for: a document swapped under a running agent.
The one case a local load cannot check is a pin whose author_keys are all
instruction://…keys.json JWKS URIs — resolving one needs the registry client.
Point author_keys at a key file to verify a file/dir/url/oci document;
unenforceable decides what that case means. A build without --features sign
cannot check any signature, so a pin there is a startup refusal rather than a
silent pass.
Verifying the artifact too — oci: {ref, cosign_key}
trust answers who wrote this document. For an OCI artifact there is a
second, independent question — who pushed this artifact — and cosign answers
it:
agent:
instruction:
oci:
ref: "ghcr.io/acme/agent@sha256:…"
cosign_key: /etc/keys/cosign.pub # PEM PUBLIC KEY (P-256 or Ed25519)
With a key configured, the pull fetches the signature cosign stores beside the
artifact (the sha256-….sig tag in the same repository), verifies it against
the key, and checks that the signed payload names this manifest digest —
so a valid signature over a different artifact is refused. An unsigned artifact
is refused too. The §7.7 freshness re-pull runs the same check: a moved tag is
a new artifact and gets the same scrutiny.
cosign_key is not special to agent.instruction: every place a document can
come from — agent.prompt, a subagent template's instruction — resolves
through the same code, and takes the same oci: {ref, cosign_key}.
Public-key cosign only. Keyless verification would put a Fulcio certificate
chain and a Rekor transparency-log lookup in the startup path, which is a poor
trade for a runtime that counts its dependencies — the verification itself
reuses ring, which the oci feature already builds.
The two mechanisms are complementary, and neither substitutes for the other: a registry compromise can serve a genuinely-authored document from the wrong place, and a stolen push credential can publish an artifact nobody authored.
What a signature is not. An oci://…@sha256: reference pins the bytes
immutably, which defeats a tag swap but says nothing about who wrote them.
TLS authenticates the host you dialled, often a CDN. An encrypted envelope
proves the sender had your public key, not authorship. The pins here are the
only thing that answers "who wrote this".
unenforceable decides what that means, because a security control that
silently does nothing is worse than an absent one:
| Value | Behaviour | Use when |
|---|---|---|
warn (default) | one line at startup naming the pin and why its keys cannot be resolved | the pin is right for production, where the registry serves the JWKS |
refuse | exit 2 | the pin is the point — if the signature cannot be checked, this is not the agent you meant to run |
ignore | silence | one config deliberately shared across deployments that differ |
Restart-only. Widening what a source may attest, or which keys are trusted, is never a hot reload — a publisher an operator believes they revoked must not stay live. It is the one restart-only setting inside an otherwise reloadable one, declared as such so the partition stays checkable (§11).
5a.4 Encrypted instructions
Any source may deliver an encrypted envelope (age v1 or JWE compact); the recipient keys live with the instruction:
agent:
instruction:
oci: "ghcr.io/acme/agent:latest"
decrypt:
keys: ["/etc/keys/agent.key"] # AGE-SECRET-KEY-1…, hex, or base64
passphrase: "{{secret:doc_pass}}" # for age scrypt envelopes
Decryption happens before anything interprets the bytes, so an encrypted file,
artifact or served resource all work the same way. Decrypting grants nothing:
the trust ladder and the trifecta apply to the plaintext exactly as if it had
arrived in the clear. A build without --features decrypt still RECOGNISES an
envelope and refuses it by name — ciphertext is never delivered to a model as
prose. See directives.md.
6. Process shape — lifecycle.run_until and start nodes
Two independent settings decide whether the process is a one-shot job or a long-lived daemon, and what wakes it:
lifecycle.run_until—idle(a job: exit once no runs, turns, or pending inbox remain, afterlifecycle.idle_grace, default5s),drained(a daemon: never exits on its own; a SIGTERM finishes in-flight work then exits0), orauto(the default: a job unless the instance has ana2a.listenor a long-lived start node — then a daemon).- a workflow start node — the trigger that fires runs. One workflow may have several.
start kind | fires a run… | key fields |
|---|---|---|
once | once, at startup (unless a live run was restored) | policy |
manual | only when explicitly triggered (workflow.run, or an A2A workflow.run command) | — |
loop | repeatedly, on an interval, until a condition | interval, delay, until, max_iterations, backoff |
schedule | on a clock | cron: "0 2 * * *" (needs --features cron), or every: 1h, or at: "02:00Z"; plus tz, jitter, catch_up |
subscribe | when an MCP resource updates | server, uri (both required), debounce_ms, coalesce, filter, deliver, on_no_listener, window |
signal | when a named signal arrives | name (required), filter, deliver |
event | on a runtime event | on (required — the event name as the runtime spells it, e.g. workflow.finished), filter |
stream | on each event of a declared stream | stream (required), subject (exact or prefix.*), filter, from (new | earliest) |
correlate | when a set of related stream events has arrived — a join over events, not steps | stream, on (two or more subject patterns) and window (all required), by, on_incomplete, filter, max_pending |
webhook | on an inbound HTTP request | path (required), methods, auth, parallelism, on_overflow, rate, idempotency, respond |
The long-lived kinds — every start kind except once and manual: loop,
schedule, subscribe, signal, event, stream, correlate, a2a,
webhook — make the instance a daemon under run_until: auto, and a daemon is
durable: with no store section it gets kind: file on the local filesystem
(§12.3), and an explicit kind: none on a daemon is exit 2 (§2). A bare
a2a.listen does the same without any start node: an inbound A2A message
becomes a conversation turn directly. (There is also an a2a start-node
kind: declaring command: on it REGISTERS an A2A command the listener
accepts, and a matching inbound message fires a run instead of a
conversation turn — examples/hiring/actions.yaml and examples/startup/
are built on it.)
workflows:
- name: watch-queue
steps:
s: { kind: subscribe, server: queue, uri: "queue://inbox" } # the trigger
t: { kind: agent, depends_on: [s], instruction: "Triage the new item." }
f: { kind: finish, depends_on: [t] }
Every workflow needs a start node and a finish step, and every non-start step
declares depends_on; --validate-config runs the same workflow parse the
runtime does for inline steps:, so a mistyped field is caught before the
first side effect (§2). A file:, dir: or url: reference carries no
steps in the config document itself; it is resolved at startup, when the file is
read or the URL fetched — so a typo inside a referenced definition (or a dir:
that matches no file) surfaces there instead.
The job shape needs no workflow at all: agentd --instruction "…" --intelligence https://… expands to a once → agent → finish workflow, runs one
turn, and exits.
6.1 Where definitions come from
An entry in workflows: is any ONE of:
workflows:
- name: inline-one # inline: the steps live in this file
steps: { … }
- name: from-file # a local file (YAML or JSON)
file: ./workflows/triage.yaml
- name: from-url # fetched at startup, fail-closed
url: https://config.internal/workflows/triage.yaml
headers: { authorization: "Bearer {{secret:WF_TOKEN}}" }
timeout: 10s # default 30s
allow_private: true # the fetch rides the same SSRF guard as http nodes
- dir: ./workflows # every match becomes a workflow, named by file stem
- dir: # …or the folder carries its own settings
path: ./workflows
glob: "**/*.yaml" # `*` within a segment, `**` crosses segments
order: date # name (default, path order) | date (mtime, oldest first)
A url fetch happens once, at startup, before validation — an unreachable URL
or a non-parsing body is exit 2, not a daemon that silently runs without the
workflow. headers follows the same no-inline-credential rule as every other
header map (§3). A dir with zero matches is also exit 2: an empty glob is
almost always a typo, and fail-open here means a reactive daemon with no
reactions. However a definition arrived, it is hashed and pinned identically —
a run started under one hash finishes under it.
dir: is the same folder source an
instruction folder takes —
a path, or {path, glob, order} — running the same code — a folder of documents is one behaviour, not two that
look alike. With lifecycle.watch_config on, both the named files and the
folders are watched, and a reload re-reads them even when the entries
themselves did not change.
security.workflows.immutable: true makes the loaded set read-only for the
agent itself: workflow.create / workflow.update / workflow.delete tool
calls are refused (logged as workflow.locked), so a model cannot rewrite its
own standing orders — the definitions are exactly what the operator deployed,
GitOps-style. Operators still change them by editing the source and restarting
(or hot-reloading).
Scope. Reactivity rides the MCP servers' Streamable-HTTP subscriptions. The A2A listener (
a2a.listen) is HTTP(S) with mTLS/bearer auth (loopbackhttp://for dev). For time-scheduling at scale, prefer an externalCronJobfiring a job per tick; the built-inloop/schedulestart nodes are the standalone convenience. A schedule that comes due while the process was down fires once on restart — missed occurrences collapse rather than replay.
See modes-and-triggers.md for the lifecycle in depth
and workflows.md for the node catalogue —
agentd --workflow-schema prints the authoritative registry.
6.2 Directives — an instruction that carries its machinery
agent.instruction (inline, a file, an artifact, a served resource) may embed
colon-fence directives — the :::type{attrs} … ::: container syntax
MyST and ChatGPT readers already know:
agent:
instruction: |
You watch the queue and keep things tidy.
:::!workflow{name=triage}
steps:
wake: { kind: subscribe, server: queue, uri: "queue://inbox" }
act: { kind: agent, depends_on: [wake], instruction: "triage the item" }
done: { kind: finish, depends_on: [act] }
:::
:::!skill{name=tidy description="how we tidy"}
Always sweep before you mop.
:::
:::context{title="ops notes"}
The queue drains overnight.
:::
Four directives. The machinery ones are fail-closed — an unknown :::!name is
exit 2, naming the line and the kinds this reader knows — while an unknown
bare name is deliberately inert: it stays prose, delivered verbatim:
:::!workflow— the YAML body joinsworkflows:exactly as an inline entry: same{{config.*}}folding, validation, hashing, pinning, and retirement (§6.1, workflows doc §retirement). The model reads the cleaned instruction, where the block became a one-line note — prose and machinery never double-speak. An instruction that carries a workflow gets no sugarmainloop: it declared its machinery explicitly. That holds however the document was named — inline, a file, a folder, an artifact — because the decision is made after the source resolves and its directives are extracted.:::!skill{name, description, when}— an inline skill: the body joins the skills catalogue with no MCP server involved, referenced as@skill:<name>like any discovered skill. Inline wins a name collision — the operator wrote it closer to this agent than any server did.:::context{title?}/:::example— model-facing: the fence goes, the body stays, wrapped in<reference>/<example>tags.
Editing the instruction and reloading (SIGHUP / watch_config) re-extracts:
an embedded workflow whose body changed is replaced (new runs on the new
hash, live runs finish pinned), one that disappeared is retired under its
unload: policy. Directives are parsed only from operator-authored
surfaces — never from conversation text; executing definitions out of less
trusted text would be prompt injection as a feature.
The full story — the precise grammar, the trust rule, retirement, and what
is deliberately out — is directives.md.
7. Duration syntax
Every duration-typed path — limits.run.deadline (--deadline),
limits.step_timeout, lifecycle.drain_timeout (--drain-timeout),
lifecycle.idle_grace, intelligence.timeout, mcp.default_timeout,
a2a.conversation_ttl, security.exec.timeout, and the interval/every/
timeout fields of workflow nodes — accepts a number with an optional unit
suffix. A bare integer means seconds.
| Input | Meaning |
|---|---|
250ms | 250 milliseconds |
600s | 600 seconds |
5m | 5 minutes (300 s) |
2h | 2 hours (7200 s) |
30d | 30 days |
2w | 2 weeks |
30 | 30 seconds (bare = seconds) |
Recognized units: ms, s, m, h, d, w. An empty string, an unparsable
number, or an unknown unit is a usage error (exit 2), e.g.
unknown duration unit 'x' in 2x or invalid duration: nope. Rate windows
(rate: "<burst>/<per>") accept the same units — 1/1d is one per day.
8. Run ID & idempotency
lifecycle.run_id (--run-id / AGENT_RUN_ID) is the idempotency key
propagated into every outbound MCP tools/call _meta — alongside
agent/instance and a traceparent — so backing services can dedupe retries.
- Default — when unset, agentd mints a fresh ULID. It correlates logs/traces across the subagent tree but does not dedupe retries (each retry gets a fresh id).
- For retry-dedupe — the operator sets a stable key per logical unit of
work (e.g. a K8s Job name or an input hash), so the same work reuses the same
run_idacross retries.
$ agentd --instruction 'enqueue digest' \
--intelligence https://gw.example/v1 \
--mcp queue=https://mcp-queue.internal/mcp \
--run-id "$JOB_NAME"
agentd introduces no local non-idempotent side effects: its own durable state
(runs, memory, artifacts) goes through the configured store, and every other
effect is externalized through MCP, which is where the key does its work.
9. Drain timeout & signals
--drain-timeout (default 25s) bounds the graceful drain on
SIGTERM/SIGINT. A clean drain exits 0, not 143. Keep the drain timeout
strictly less than the pod's terminationGracePeriodSeconds (recommended
30) so the supervisor's own ladder finishes before the kubelet's SIGKILL
lands.
# a daemon (an a2a.listen and/or a subscribe start node + a durable store — see §6),
# with a bounded graceful drain:
$ agentd --config daemon.yaml --drain-timeout 20s
A second SIGTERM/SIGINT forces an immediate SIGKILL of all process
groups.
SIGHUP reloads in a --features hot-reload build (§11): it re-reads the
config files and applies the reloadable subset at a quiesce boundary,
validate-first. In a build without hot-reload, SIGHUP keeps its default
disposition (terminates) — restart to reconfigure. Restart-only paths
(store, lifecycle.run_until/run_id/drain_timeout, a2a.listen/a2a.tls/
a2a.bearer, security, …) never reload (§11).
10a. --effective-config — what am I running, and who said so
--validate-config answers is this valid. This answers what is it:
$ agentd --effective-config | jq .provenance
{
"agent.instruction": "file /home/me/.config/agentd/config.yml",
"agent.name": "file ./agentd.local.yml",
"intelligence.model": "flag",
"intelligence.endpoints": "env",
"workflows": "convention (folder beside the config)"
}
One JSON object on stdout:
| Key | What it is |
|---|---|
config | the document the loader assembled — files, then environment, then flags, then the conventional folders, with ${VAR} expanded |
provenance | per setting, the layer that changed it. A later layer restating a value identically is not credited: it changed nothing, and naming it would send you to edit the wrong file |
document_config | the :::!config fragment the instruction declared. It merges under config (an explicit setting still wins) and is applied after the document is typed, so it is the one thing a config file cannot show you |
files | the config files that were loaded, in layer order |
notes | the caveats above, in the output itself |
Three things worth knowing:
- It runs on a broken config. The moment you most need to know what is in
effect is when something is wrong, so validation errors are reported on
stderr beside the document rather than replacing it. Exit is
0: this is a report, not a verdict —--validate-configowns the verdict. - Credentials are redacted. A config file may not carry a live credential,
but an env var or a flag may, and
${VAR}expansion has already happened by the time the report is built. Any value under a credential-shaped key becomes"<redacted>"; a{{secret:…}}reference is shown as written, because which reference a setting uses is one of the things you need to see. - It resolves the instruction, like every other load. Naming a
file:,dir:,url:oroci:instruction means this command reads that file or folder, and fetches that URL or artifact — the same work--validate-configdoes, because the document's:::!configcan only be reported after it has been read. It writes nothing. - Defaults are not shown. A path absent from
configtakes the default in--config-schema. The report is what was stated, plus what agentd generated (amainworkflow synthesized for a bare--instructionrun is in there, attributed to the generator — a report that omitted it would describe a config nobody runs).
10. Observability of config
On startup agentd validates and emits structured JSON-lines telemetry on stderr; the credential is always redacted. Example shapes:
{"event":"config.warning","msg":"store.kind is none: this one-shot run is not durable (a crash re-runs it from scratch); set store.kind for durability"}
{"event":"config.invalid","msg":"a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set"}
{"event":"store.file","path":"/var/lib/agentd","generation":3,"defaulted":true,"msg":"durable state is on the local filesystem; it survives a restart of this process but not a move to another host — use store.kind mcp|http for a fleet"}
Once the configuration is accepted the supervisor announces itself:
{"level":"info","event":"proc.ready","comp":"supervisor","instance":"agentd","job_shape":true,"workflows":1,"runs":0,"inbox_pending":0,"run_id":"01M06…","trace_id":"a21f2d…","pid":924712,"ts":"…"}
Content-capture stays off: no startup line carries the instruction body, an
endpoint credential, or a header value — header names only. The full event
schema is in observability.md. A reload adds
config.reloaded, config.reload.invalid, and
config.reload.restart_required (§11).
11. Hot reload & the reloadable/restart-only partition
In a --features hot-reload build a running daemon applies a new config without
a process restart. Two triggers funnel into the identical reload routine:
SIGHUP— the portable, dependency-free default (always available whenhot-reloadis built).lifecycle.watch_config/--watch-config(--features config-watch) — aninotifywatch on each config file's parent directory, so a Kubernetes ConfigMap volume swap (an atomic directory-symlink rename) is seen and reloads in place. Needs a--config/AGENT_CONFIGfile (else exit2— watching nothing is a usage error).
Reload is validate-first: the files are re-read and re-merged through the
same load + validation pipeline as startup (built-in < files < env < flags). An
invalid candidate is refused with config.reload.invalid — the running config
is kept, nothing is half-applied. A coherence check then refuses the reload
with config.reload.restart_required if any restart-only path changed,
naming the paths that differ.
Reloadable (applied live at a quiesce boundary; the flat tree does most of the work — every turn worker is spawned fresh from the live settings, so the next unit of work picks the new values up):
intelligence.endpoints/model/token/token_file— repointed via the runtime hot-swap primitive; in-flight turns follow theswap_policy— andintelligence.budget(fresh windows, counters carried over)agent.instruction(a resource instruction re-subscribes) and the rest ofagent—preflight,wake_on,tools,max_parallel_turns,on_workflow_finished,conversation_budgetmcp— re-handshaked live: removed servers disconnect, added or changed servers connect + initialize; unchanged ones are left alonetools,knowledge,search— the tool registry is rebuilt (a registry that fails to build refuses the reload and keeps the old one)skills— the catalogue is re-discoveredworkflows— definitions reload and re-arm; live runs stay pinned to the definition hash they started withlimits,lifecycle.idle_grace,observability.log_level/log_content,memory,contexta2a.principals— the rules are recompiled and swapped into the live listener, so a demotion or a rotatedbearer_reftakes effect on the next request. A rebuild that fails (an unresolvable{{secret:…}}, a malformed matcher) keeps the rules already in force rather than falling openwebhooks.default_authand thewebhookroutes themselves (which live inworkflows[]) — the route table is rebuilt on every reload, so a{{secret-file:…}}rotated by a remounted Kubernetes Secret is picked up even though the config document did not change. Live per-route state (the in-flight count, the rate-limit bucket) is carried across the rebuild, so reloading does not hand a caller a fresh burst allowanceinterface.origins— the browser CORS allowlist is replaced in the live listener, so removing an origin actually revokes it
Restart-only paths — a reload whose effective document differs under any of
these is refused with restart_required (roll the pod instead):
config_version, agent.name, agent.document_capabilities,
agent.instruction.trust (§5a.3b), store.kind, store.prefix, store.mcp,
store.http, store.file, store.max_value_bytes, lifecycle.run_until,
lifecycle.drain_timeout, lifecycle.run_id, lifecycle.exit_code_map,
lifecycle.watch_config, a2a.listen, a2a.tls, a2a.bearer,
interface.enabled, interface.pairing, webhooks.listen, webhooks.tls,
observability.otel, observability.metrics_addr,
observability.health_file, observability.events_ring,
observability.traceparent, security.
The webhook and interface entries name the socket, not the rules: rebinding
an address, swapping a TLS identity, arming the observation feed or the pairing
flow are all startup decisions, while the auth and routing on top of them
reload. interface.enabled is restart-only even though turning it off would
work (those gates are read live) — a knob that reloads in one direction only is
worse than one that plainly refuses.
Every configuration path is classified as one or the other, and a test
(every_config_path_is_classified) walks the generated schema to prove it. That
matters because the failure this prevents is silent: a field captured into a
long-lived structure at startup, listed in neither partition, reports a
successful reload and changes nothing. a2a.principals, the webhook routes and
interface.origins were each exactly that before v1.4.0.
store.file is restart-only for the same reason as the rest of store: moving
the state directory under a running instance would strand every key it has
already written there.
Every applied reload logs config.reloaded with the changed groups, bumps
lifecycle.config_generation in the durable manifest, and is audited as a
config.reload action.
12. The config file (--config)
--config <PATH> (repeatable) / AGENT_CONFIG loads one or more documents in
YAML or JSON (§12.2 for how several compose). The extension picks the syntax
(.yaml/.yml ⇒ YAML, .json/.jsonc ⇒ JSON with ////* */ comments);
any other extension is sniffed (a document starting with {/[ is JSON, else
YAML). YAML is read by agentd's own
dependency-free subset reader (mappings, sequences, flow collections, quoted /
plain / block | > scalars, comments, YAML 1.2 core typing — yes/on are
strings, not booleans); anchors/aliases, tags, merge keys and multi-document
streams are rejected with a line/column error, as are tab indentation and
duplicate keys. Both syntaxes yield the same document, validated identically.
It is the lowest non-default precedence layer: env and flags override it, and
repeatable list flags (--mcp/--a2a-peer/--workflow) add to the file's
lists. An unknown key is a hard error (deny_unknown_fields → exit 2) naming
the file and listing the fields that are allowed — the most common config typo,
closed at parse time. Print the schema with --config-schema (Draft 2020-12,
exit 0); validate a candidate with --validate-config.
12.1 The discovery chain — the config you did not name
When an invocation names no config — no --config, no AGENT_CONFIG —
agentd walks a three-rung chain and loads every rung that has a file, the way a
linter or a formatter picks up its dotfile:
| Rung | Looked for | For |
|---|---|---|
| user | $XDG_CONFIG_HOME/agentd/config.yml (also config.yaml), else ~/.config/agentd/config.yml (also config.yaml) | defaults that follow the person, not the checkout |
| project | ./agentd.yml (also agentd.yaml, .agentd.yml, .agentd.yaml) | the config a repository checks in |
| local | ./agentd.local.yml (also .yaml) | one machine's overrides, expected to be git-ignored |
They merge in that order — user, then project, then local — with flags and
environment still on top. So a checked-in project config makes agentd work
with no flags at all, and pointing one checkout at a dev endpoint never means
editing a tracked file:
$ cd ~/work/triage # contains agentd.yml and agentd.local.yml
$ agentd --validate-config
{"event":"config.valid","files":["/home/you/.config/agentd/config.yml","./agentd.yml","./agentd.local.yml"],"schema":"1"}
Four rules keep it from being surprising:
- It is only ever a fallback. Naming a config — by flag or by env — means
you have already decided; no rung is consulted, merged, or layered
underneath. There is no way for a stray
agentd.local.ymlto modify a run you spelled out. - The project and local rungs are the working directory only. No walk up to
a parent, no
/etc. Where they apply is exactly where you can see them. - Two spellings of ONE rung is an error (exit
2), not a silent pick between them — whichever agentd chose, somebody would be editing the other and wondering why nothing changed. Ambiguity is per rung:agentd.ymlbesideagentd.local.ymlis the design;agentd.ymlbesideagentd.yamlis a coin toss. - No rung may relax a security control. Every discovered file is unnamed,
so
security.allow_trifectaand its kind are exit2from any rung — a machine-local overlay must not be able to widen what the project file could not. Pass--configif you meant it.
The dotted spellings (.agentd.yml, .agentd.yaml) are the original discovery
names and stay valid; agentd.yml is the name to teach.
--help, --version, --config-schema and --workflow-schema never discover
a config, so a malformed file cannot stop you from reading the help.
Once discovered they are ordinary file layers: env and flags still override
them, and --watch-config watches each like any other.
12.1.1 Conventional folders beside the config
Four folders beside the config fill in settings you did not write, so a project can be a directory you read rather than one long file:
| Folder | Fills in | Files |
|---|---|---|
workflows/ | workflows: | *.yaml, *.yml, *.json — one workflow document each |
skills/ | skills.dir | <name>.md, or the Agent Skill form <name>/SKILL.md |
subagents/ | subagents.templates | *.yaml, *.yml, *.json — one template each, named by file stem |
context/ | context.templates | *.md, *.txt, *.hbs — the whole body is the template, named by stem |
They are looked for beside each config file that was loaded, most specific
first (the last file, then the one before it, and so on) — the first of those
directories that has the folder wins; or the working directory when no config
file was loaded at all. Load order within a folder is filename order, so 10-,
20- prefixes work — though a workflow still needs a legal name: inside it,
since names must match [a-zA-Z_][a-zA-Z0-9_-]{0,63} and cannot start with a
digit.
Two rules make these conventions rather than declarations:
- Only when the setting is absent. Writing
workflows:— including an empty list, meaning none — is a decision, and the folder does not argue with it. - Only when the folder yields something. A missing or empty folder is
silence. (A
dir:you named with no match is still exit2: you asked for it by name.)
A skills/ folder is the one that adds a capability rather than moving one:
skills previously reached agentd only through an MCP server or an inline
:::!skill directive. A skill grants no tool — it is prose the model reads — so
a local file needs no server. Frontmatter is optional: with none, the file stem
names the skill and its first paragraph describes it. Like :::!skill, a local
file wins a name collision with a discovered one.
12.2 Several files — later overrides earlier
The files in play are, in order: every entry of AGENT_CONFIG (a :-separated,
PATH-style list), then every --config <path> in argument order. They compose
into one document with JSON-Merge-Patch semantics (RFC 7396): objects
merge key by key (recursively), scalars and lists are replaced by the later
file, and an explicit null unsets a key. Each file is type-checked on its
own (an unknown key names the file it is in), then the merged document is
applied as the file layer — env and flags still override it. proc.start lists
the merged files (config_files); with --watch-config, every file is watched
and a change to any of them re-merges the whole set on reload.
$ AGENT_CONFIG=/etc/agentd/base.yaml \
agentd --config /etc/agentd/site.yaml --config ./local-overrides.yml …
# base.yaml < site.yaml < local-overrides.yml < env < flags
# base.yaml # site.yaml
intelligence: intelligence:
model: default-model model: site-model # replaces
limits: limits:
run: { steps: 50 } subagents: { depth: 3 } # merges: run.steps 50 kept
subagents: { depth: 4 }
tools: tools:
disabled: [a] disabled: [b] # REPLACES: [b]
observability: observability:
log_level: debug log_level: null # unsets → built-in default
12.3 What the file carries
The file carries the whole schema — every section named at the top of this
page, i.e. every path agentd --config-schema prints. There is no separate
"file-only" subset:
each path is equally reachable from env and flags (§1.1), so
limits.run.steps ⇒ AGENTD_LIMITS_RUN_STEPS / --limits.run.steps, and
mcp.servers ⇒ AGENTD_MCP_SERVERS='[{name: fs, endpoint: https://…}]'.
| Section | Carries |
|---|---|
config_version | "1". Optional, but pin it — any other value is exit 2. |
vars | Named values (any JSON type, nestable) referenced as {{config.NAME}} anywhere a string sits — see §12.4. |
agent | name, instruction, prompt, preflight, wake_on, tools (internal/mcp/code allow-lists), max_parallel_turns, conversation_budget, ask_human_fallback, on_workflow_finished. |
intelligence | endpoints[], model, dialect, swap_policy, timeout, headers{}, token/token_file, auth{} (OAuth 2.1 / AWS SigV4 / SPIFFE), budget{}, structured_output. |
mcp | servers[] — {name, endpoint, headers{}, tags{glob:[…]}, ns, allow[], exclude[], timeout, auth{}, oauth{}, aauth} — and default_timeout. allow/exclude gate the server's advertised tool names by glob (exclude beats allow; a gated-out tool never registers). |
tools | disabled[], overrides{} (retarget a tool at a declared server, optionally rewriting args/result). |
context | template (the system-prompt template; unset = the built-in, printed by agentd --context-template), templates{} (named alternates a node picks with context: {template: <name>}), summarize{prompt, model} (the compaction guidance and a cheaper model to run it on), compact_at, keep_last, model_window, plan{}. |
store | kind (file|mcp|http|memory|none), the matching file{path, min_free} / mcp{} / http{} block, prefix, timeout, on_error, durability{a2a, steps, work}, checkpoint{}, audit, retention{runs{keep_last, ttl}}, max_value_bytes. Defaults per instance shape — see below. retention.runs bounds durable run records: a terminal run is dropped once it falls outside keep_last (newest first) or past ttl. Nothing in flight is ever dropped. The default is unbounded, so a long-lived instance keeps one record per run for its whole uptime — set one of these and steady-state size tracks concurrent runs rather than uptime. max_value_bytes refuses a durable write larger than N bytes. Set it when the store's READ limit is lower than its write limit — an MCP store reached through a broker often caps a tool RESULT well below its request body, so agentd can write a checkpoint it cannot read back and the failure lands on the next boot restore rather than on the write that caused it. Over the cap, the write is refused (store.on_error decides what happens next) with a message naming the key, the size and the cap; nothing is stored. Unbounded by default, and restart-only (§11): the cap rides the policy built once at startup, so a reload that changes it is refused rather than leaving writes refused at the old value an operator believes they raised. durability.work: ephemeral flips the deployment's durability CLASS: runs and subagent records are memory-only unless a workflow says durable: true (docs/workflows.md §durability) — the fast path when all work is recomputable. |
workflows | Inline definitions, or {name, file} / {name, uri} / {name, url, headers, timeout, allow_private} references, or a folder scan — {dir: <path>} or {dir: {path, glob, order}} (§6.1); glob and order live INSIDE dir, and a sibling glob is exit 2. security.workflows.immutable: true locks the loaded set. |
streams | Declared event streams: streams: {orders: {retention: {max_events: 10000, max_age: 7d}}}. An emit step or stream start naming an undeclared stream is exit 2. Events are durable in the store; retention trims from the head (max_events defaults to 10000). |
goal | The goal watchdog: statement, check{via,condition,every}, stuck_after, on_achieved, on_stuck. |
limits | max_message_depth (chained message deliveries; default 8), max_runs, run{steps,tokens,deadline}, step_timeout, inline_max_bytes, subagents{depth,breadth,total,rate}. |
lifecycle | run_until, idle_grace, drain_timeout, run_id, exit_code_map, watch_config (§6, §9). |
a2a | listen (https://host:port, loopback http://, or unix:///path for co-located peers — kernel-authenticated, no TLS), tls{cert,key,client_ca}, bearer, principals[], peers[] (endpoints may also be unix:///path), conversation_ttl. |
webhooks | listen, tls{}, default_auth{} for webhook nodes. |
interface | The TUI/web-UI surface served on the A2A listener: enabled, origins[], display{}, pairing{}, debug. |
memory, context, knowledge, search, skills | Working-memory caps, context window/compaction, and the MCP servers backing knowledge, search, and the skill catalogue. |
observability | log_level, log_content, metrics_addr, health_file, events_ring, traceparent, report_file, otel{}, audit{sink}. |
security | allow_trifecta, tls_ca, cgroup{}, aauth{}, exec{}, egress, workflows{immutable}, policies[] (ordered verdicts on a tool call — see security.md). |
identity | autonomous_as (who a schedule/webhook/stream firing is attributed to; default system) and labels{} carried with that work — see §15. |
The store section, and the default each instance shape gets. store.kind
picks the adapter: mcp (a coordination MCP server's state.* tools), http (a
plain HTTP key-value endpoint), file (this host's filesystem), memory
(in-process, lost on exit — dev only), none (no durable state). Set it and it
wins. Leave the whole section out and the shape of the instance decides:
| instance shape | default store.kind |
|---|---|
one-shot (no long-lived start node, no listener, no goal) | none — a job that quietly began writing state to disk would surprise everyone who runs one, and a crash simply re-runs it. |
| long-lived (§6) | file — durability a laptop or a VM already satisfies, with no backend to stand up first. |
A shared backend (mcp or http) is what you graduate to when one instance
becomes a fleet, because the file adapter admits exactly one writer.
store:
kind: file
file:
path: /var/lib/agentd # optional — the chain below applies when it is unset
prefix: agentd # as for every adapter (default `agentd`)
Where the directory comes from, first that applies — the same chain the credential cache uses, so it is one chain to learn:
store.file.path$AGENTD_STATE_DIR$XDG_STATE_HOME/agentd/state$HOME/.local/state/agentd/state- the OS temp dir — the last resort; the startup line names the path it landed
on, and state under
/tmpsurvives a restart of the process but not a reboot
Under that root the keys are the ordinary ones — <prefix>/<instance>/<kind>/<id>
— one JSON file per key, every path segment percent-encoded (so
an id of ../.. is a filename, never a directory hop). Nothing about the key
changes with the adapter, so an instance that outgrows file and moves to mcp
keeps the identity of everything it wrote.
Five things to know before relying on it:
- One process per directory, enforced. On open the adapter takes an
exclusive
flockon<root>/.lock; a second one fails at startup naming the holder —… is locked by pid 4131 — another agentd is using this state directory; give this instance its own agent.name or store.file.path. A directory has no compare-and-set that a second process would respect, so rather than pretend, it refuses. Two replicas needmcporhttp(§13). - Identity is
agent.name. It is what the<instance>segment holds (with the usual fallback to the downward-API pod name, thenHOSTNAME), so a restart finds its state again by being the same agent — not by a hash of the configuration, which would abandon in-flight work the first time somebody added an MCP server or fixed a typo. Renaming the agent is an unambiguous "this is a different instance". - Durability is the filesystem's, not agentd's. The runtime says which
directory it landed in, once, at startup (
{"event":"store.file",…}, §10), including whether the path was chosen or defaulted;--capabilitiescarries the same asstore_file: {path, defaulted}. On a container's writable layer the state survives a process restart and not a reschedule — mount a volume, or usemcp/http(deployment.md). - The disk is watched —
store.file.min_free. A checkpoint that hitsENOSPChalts the daemon, so the runtime measures the store filesystem's headroom (~every 2 s) and sheds before that happens: belowmin_free(default256MB;1.5GiB, plain bytes,"0"disables) no new work is admitted — schedules skip with astart.shedline, webhooks answer429 Retry-After, queued turns stay queued — while everything in flight drains normally. Warn at twice the threshold. Transitions are logged once (pressure.warn/pressure.shed/pressure.cleared) and exported asagent_pressure_level/agent_disk_free_bytes(§10). Seeoperations.mdfor the full shed/drain story. 0700directories,0600files, no encryption at rest. The state holds conversation content and tool results, and is protected exactly as the credential cache is: by the user the daemon runs as. If that is not enough, pointstore.file.pathat an encrypted volume. No tool the model can call reaches it — there is nofstool, and the store is the runtime's own ledger, not part of the agent's surface.
Secrets stay out of the file. Four paths are credential-bearing and are
rejected outright when a file supplies a literal: intelligence.token,
a2a.bearer, security.aauth.enroll_token, and
mcp.servers[].oauth.client_secret. So is any credential-shaped header key
(Authorization, …) with an inline value, in intelligence.headers, an MCP
server's headers, an A2A peer's, or store.http.headers. From env or a flag an
inline value is fine; from a file it must be a reference:
{{secret:NAME}}— resolved from the environment variableNAME.{{secret-file:PATH}}— resolved by reading the mounted file atPATH.
References resolve at startup, not at --validate-config (which is
deliberately environment-independent): an unset env var or an unreadable file is
agentd: intelligence.token: {{secret:LLM_KEY}} is not set in the environment →
exit 2 before the first dial. The resolved value is never stored in the
settings or logged — header NAMES only ever reach the logs, and the operator-only
A2A config command returns the merged document with the {{secret:…}}
references still unresolved.
The startup preflight collects every unresolved reference across the config
and all loaded workflow definitions and reports them together — one restart
fixes the list, not one line per restart. Interactively, --prompt-missing
turns that list into prompts: each missing {{secret:NAME}} is asked for on
/dev/tty (echo off, one by one — the same experience as agentd login),
values live only in process memory, and a restart re-asks. Without a controlling
terminal the flag refuses and the normal aggregate error stands. Prompted values
resolve exactly like environment ones — including inside workflow steps — they
are just never persisted anywhere.
12.4 vars — named values for the config and its workflows
vars:
region: eu-1
api_base: "https://api.eu-1.internal"
batch: { size: 20, parallel: 4 }
intelligence:
endpoints: ["{{config.api_base}}/v1"]
workflows:
- name: sync
steps:
pull: { kind: http, url: "{{config.api_base}}/items?region={{config.region}}" }
# exact-token references keep the var's TYPE:
each: { kind: batch, over: "{{steps.pull.output.json}}", size: "{{config.batch.size}}" }
{{config.NAME}} (dotted paths reach into nested values) is substituted at
load time — before validation, before the definition hash — so a workflow
fetched from a URL and one written inline resolve identically, and the hash pins
the resolved definition. A string that is exactly one token takes the value
typed (size above is a number); embedded tokens stringify into place. The
namespace is deliberately config., not vars. — vars. is the run
namespace assign writes at runtime; these are deployment constants. An
undefined reference is exit 2, all misses reported together; there is no
escape syntax, because a URL still containing {{config.region}} at runtime is
a bug wherever it was headed. Values are plain data, not secrets — credentials
keep using {{secret:…}}, and the credential lint of §12.3 still applies to
them.
A YAML example (/etc/agentd/config.yaml):
# structural config; secrets stay in env / mounted files
config_version: "1"
agent:
name: triage
instruction: You triage incoming items and escalate the risky ones.
intelligence:
endpoints: [https://primary.internal/v1, https://fallback.internal/v1]
model: my-model
swap_policy: finish-on-old
token: "{{secret:LLM_KEY}}" # a reference, never inline
headers:
anthropic-version: "2023-06-01"
mcp:
servers:
- name: fs
endpoint: https://mcp-fs.internal/mcp
tags:
"*": [sensitive]
- name: web
endpoint: https://mcp-web.internal/mcp
headers:
Authorization: "Bearer {{secret:WEB_TOKEN}}"
tags:
"*": [untrusted_input]
store:
kind: mcp
mcp: { server: fs }
limits:
run: { steps: 80, tokens: 150000, deadline: 5m }
subagents: { depth: 3 }
observability:
log_level: info
And a JSON one:
// /etc/agentd/config.json — structural config; secrets stay in env / mounted files
{
"config_version": "1",
"agent": { "instruction": "Triage the inbound queue." },
"intelligence": {
"endpoints": ["https://primary.internal/v1", "https://fallback.internal/v1"],
"model": "my-model",
"token": "{{secret-file:/var/run/secrets/llm-key}}",
"headers": { "anthropic-version": "2023-06-01" }
},
"mcp": {
"servers": [
{ "name": "fs", "endpoint": "https://mcp-fs.internal/mcp",
"headers": { "authorization": "Bearer {{secret:FS_TOKEN}}" },
"tags": { "*": ["sensitive"] } },
{ "name": "queue", "endpoint": "https://mcp-queue.internal/mcp" }
]
},
"store": { "kind": "mcp", "mcp": { "server": "fs" } },
"limits": { "run": { "steps": 80, "deadline": "5m" }, "subagents": { "depth": 3 } }
}
$ agentd --config /etc/agentd/config.json \
--instruction /etc/agentd/task.md # instruction + secrets via env/flag
For the reloadable-vs-restart-only partition of these fields, see §11.
13. Running a fleet
There is no cluster section, no --shard flag, and no per-start claim or
shard option. agentd carries no coordination protocol of its own, because
coordination needs a shared source of truth and agentd already talks to two that
are better placed to own it: the MCP server the work comes from, and the store.
So a fleet partitions upstream: one queue subscription per replica, or a
coordination server that hands out work and takes it back when a lease expires.
Both are described, with working config, in scaling.md.
One consequence for the store: a fleet needs kind: mcp or kind: http. The
file adapter (§12.3) is a single-writer store and says so at startup — the
second process to open the directory fails with the first one's pid rather than
interleaving writes into it.
14. A complete example
A daemon that serves A2A, watches a queue, and runs a durable workflow — the
whole configuration in one config_version: "1" file:
# /etc/agentd/agentd.yaml
config_version: "1"
agent:
name: triage
instruction: You triage incoming items and escalate the risky ones.
preflight: auto
intelligence:
endpoints: [https://llm.internal/v1, https://llm-fallback.internal/v1] # ordered failover
model: my-model
token: "{{secret:LLM_KEY}}" # a reference, never the value
budget:
windows: [{ per: hour, tokens: 2000000 }] # rate-limit the token burn
mcp:
servers:
- { name: fs, endpoint: https://mcp-fs.internal/mcp }
- { name: queue, endpoint: https://mcp-queue.internal/mcp }
- { name: state, endpoint: https://mcp-state.internal/mcp }
store: # a daemon must be durable
kind: mcp
mcp: { server: state }
a2a: # the external channel
listen: https://0.0.0.0:8443
tls: { cert: /tls/cert.pem, key: /tls/key.pem, client_ca: /tls/clients.pem }
principals:
- { match: { san: "spiffe://ops/*" }, role: operator }
- { match: { san: "spiffe://team/*" }, role: user, grants: [workflow.*] }
workflows:
- name: watch-queue
steps:
s: { kind: subscribe, server: queue, uri: "queue://inbox" } # the trigger
t: { kind: agent, depends_on: [s], instruction: "Triage the new item." }
f: { kind: finish, depends_on: [t] }
limits: { max_runs: 8, run: { steps: 80, tokens: 150000, deadline: 5m }, subagents: { depth: 3 } }
lifecycle: { run_until: drained, drain_timeout: 20s }
observability: { log_level: info, health_file: /run/agent/health, metrics_addr: "127.0.0.1:9090", audit: { sink: [log, store] } }
security: { cgroup: { spec: auto, memory_max: 2G } }
$ agentd --config /etc/agentd/agentd.yaml
Any path is also an env var and a flag, so a container overrides at deploy time
without editing the file (built-in < file < env < flag):
$ AGENTD_INTELLIGENCE_MODEL=my-other-model \
agentd --config /etc/agentd/agentd.yaml --limits.run.steps 120
The job shape (a CLI one-shot) is just the --instruction sugar — it expands
to a once → agent → finish workflow, runs one turn, and exits:
$ agentd --instruction "Summarise the incident." \
--intelligence https://llm.internal/v1 --model my-model
15. Identity — who work is done for
A schedule, webhook, stream or once start carries no caller, so autonomous
work used to pass no principal at all: "every effect names the human or the
schedule that caused it" was false by construction, because the attribution
chain was dropped at its very first hop.
identity:
autonomous_as: "system:scheduler" # default: system
labels: {tenant: internal}
a2a:
principals:
- match: {sub: "*@acme.example"}
role: user
labels: {tenant: acme, cost_center: CC-42}
quotas:
rate: "30/1m"
budget: {windows: [{per: day, tokens: 200000}]}
What travels: the acting id and its labels reach the run record (run.start
carries acting_for), the MCP _meta as agent/acting_for and
agent/labels — so a server can finally authorize or attribute per user — and
the audit line.
The quotas now bite. quotas.budget becomes a governor scope beside
conversation: and run:, and a run is charged to the principal it is for,
not only to itself, so a per-person ceiling covers work someone started rather
than only the turns they typed. quotas.rate is a real arrival limit, with
operators exempt — locking out the person who administers the daemon during an
incident is worse than the load they could generate. Both are also checked for
shape at startup, so a typo is exit 2 rather than a ceiling that silently
does nothing.
Labels are a closed, operator-declared domain. They become durable governor scope keys and audit fields, and minting them from values arriving off the box would be the same unbounded-cardinality hazard the metrics layer already bans for labels, relocated into the manifest.
This is an audit field plus quota enforcement — deliberately not multi-tenancy. agentd's answer to "a different caller needs a different surface" remains a different process, which gives isolation a registry filter sharing an address space with a prompt-injected turn cannot.