Configuration

Every command starts by reading agent.yaml. One file drives run, serve, mcp, info, knowledge, discover and session, so a bad entry fails the same way whichever command reads it.

Where it lives

config is a single file, config/config.go, holding the Config struct and every nested block, the parser, the defaults, the validator and about seventy accessors. It imports the standard library, fisk for duration parsing, and a YAML library. Nothing else from the tree.

Three stages, in order

ParseConfigForMode is the single funnel.

  1. Unmarshal strictly DisallowUnknownField makes a typo a hard error. UseJSONUnmarshaler fills every json.RawMessage block with canonical JSON, so a per-backend options block decodes identically whether the file was written as YAML or as JSON.
  2. Prepare Derive the identity, normalize enumerated strings, parse every duration into a matching ...Parsed field, and apply defaults. This step mutates in place.
  3. Validate for a mode The checks that always run, then the ones the calling command needs.
agent.yamlbytes on diskunmarshalunknown key failspreparedefaults, durationsValidateForModeper commandModeMCPno model, no promptModeAgentidentity, prompt, modelModeServechecked per endpoint
One parser, three validation modes. The mode is chosen by the command, not written in the file.

What each mode requires

ModeUsed byRequires
ModeMCPmcp, info, knowledge, discover, session, and run against a remote workerThe always-on checks and nothing more
ModeAgentrun hosting its own agentllm.model; identity and system prompt unless the run is MCP-only; a NATS context alongside remote_tools or jobs
ModeServeservePer endpoint. Jobs, Slack and a2a prompts each need a named identity, a prompt and a model; a2a.serve_tools needs an application path and a named identity

The always-on checks cover what any command pays for getting wrong: identity charset, global_flags without an application_path, remote tool hosts, and every mcp_clients entry.

ModeMCP is the most lenient on purpose. fisk info parses in it so it can report on a configuration it could not run.

Zero is not one answer

A duration of 0s means something different in each block.

Key0s means
harness.tool_timeoutUnbounded. The operator asked for no limit
mcp_clients[].timeoutRejected. An unlimited startup holds the whole run against a server that never answers
expose.agent.a2a.request_timeoutRejected. The transport reads a non-positive value as its own shorter default, so 0s would shorten the wait rather than remove it
expose.agent.slack.answer_graceRejected. It would defer every question the instant it was asked
llm.budget.call_timeoutRejected. It reaches the provider as a deadline already in the past, and every call fails with a context error that names nothing

telemetry.sample_ratio is a *float64 because an explicit 0 would otherwise arrive as the Go zero value, be defaulted back to 1.0, and send every trace to a paid backend. llm.thinking is a pointer for three states: absent says nothing to the provider, {enabled: true} asks for thinking, {enabled: false} asks for it off.

Switches that default on are spelled negatively for the same reason: no_tui, no_bell, no_prompt_cache, no_tool_search, no_metrics, no_index, no_progress. An absent bool unmarshals to false, so a positive metrics: true could not be told apart from an unset one.

Where a mistake lands

StageExamples
ParseAn unknown key, a malformed or out-of-range duration, a negative budget, an illegal identity or alias, a duplicate MCP server name, an mcp_clients entry with neither or both transports, a ${VAR} or ${file:PATH} syntax error, api_key_env beside api_key_file
Command startupfisk mcp with no expose.agent.mcp block, fisk serve with no endpoint enabled, a knowledge subcommand with knowledge disabled, telemetry endpoint resolution, ${VAR} and ${file:PATH} resolution at connect, a missing NATS stream or bucket
RunA provider name that no linked backend answers to, a reasoning effort the model rejects on the first call, embeddings settings validated when the knowledge store opens

A confirm_tags entry that matches no loaded tool is the one case that produces a warning rather than a failure, because the tool set is only known after introspection.

Load-bearing decision

The package never reads the environment or a credential file on its own. ExpandReferences takes a lookup function and a file reader instead of calling os.LookupEnv and os.ReadFile, because the commands that parse a configuration are not the commands that connect. ${VAR} and ${file:PATH} syntax is checked at parse time and resolved at connect time, so fisk info can describe a file whose secrets are not present. ReadCredentialFile is the one reader every caller uses: it trims trailing whitespace and refuses an empty file.

Load-bearing decision

Identity is the name other agents send traffic to. When it is derived from the application basename rather than written by an operator, identityDerived records that and IdentityIsNamed() reports it. Every serving path requires a named identity, because a fleet of unrelated agents built from one shared executable would otherwise register under a single name and answer each other’s traffic. For Slack, identity is also the first field hashed into a thread’s journal.

Credentials

Secrets stay out of the file and out of tool subprocesses.

${VAR} and ${file:PATH} references
Recognized in mcp_clients env, headers and url. A value in command or args is taken literally. A relative PATH resolves under root_directory.
api_key_file and --api-key-file
The embeddings token and the model key each take a file beside their environment variable, for a Docker Compose secret. Setting a file beside its variable fails config load for api_key_file and the command for --api-key-file.
CredentialEnvNames()
Strips OTLP credential variables from every tool subprocess whether or not this agent enables telemetry. These are ambient operator variables, a tool never needs them, and gating on config would mean --no-telemetry puts the token back into every tool subprocess.
RedactURL
Applied to anything that prints an MCP endpoint. It leaves the path intact, and some hosted MCP providers put the credential in the path.

Slack credentials are absent from the file entirely and come from SLACK_APP_TOKEN and SLACK_BOT_TOKEN.

Two blocks to read carefully

expose.agent.tools narrows only MCP and a2a.serve_tools, the two endpoints that hand a caller this agent’s tools. It does not narrow jobs, slack or a2a.prompts, which hand the agent a whole unit of work and run the full loop over the top-level tool selection.

llm.budget.max_tokens counts tokens processed. It weights a cache read the same as an uncached input token although the two are priced differently, and its 500000 default was chosen to stay out of the way of ordinary use. It does not cap spend.

Not yet wired

remote_agents is declared on Config and read by nothing. Working remote-agent functionality is remote_tools. A2ATransport() returns the constant nats, with the config key deferred until a second transport exists.

Next

Configuration decides what the run may do. Continue to The agent loop for what it then does, or Tools and introspection for how the selection becomes a tool set.