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.
- Unmarshal strictly
DisallowUnknownFieldmakes a typo a hard error.UseJSONUnmarshalerfills everyjson.RawMessageblock with canonical JSON, so a per-backend options block decodes identically whether the file was written as YAML or as JSON. - Prepare Derive the identity, normalize enumerated strings, parse every duration into a matching
...Parsedfield, and apply defaults. This step mutates in place. - Validate for a mode The checks that always run, then the ones the calling command needs.
What each mode requires
| Mode | Used by | Requires |
|---|---|---|
ModeMCP | mcp, info, knowledge, discover, session, and run against a remote worker | The always-on checks and nothing more |
ModeAgent | run hosting its own agent | llm.model; identity and system prompt unless the run is MCP-only; a NATS context alongside remote_tools or jobs |
ModeServe | serve | Per 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.
| Key | 0s means |
|---|---|
harness.tool_timeout | Unbounded. The operator asked for no limit |
mcp_clients[].timeout | Rejected. An unlimited startup holds the whole run against a server that never answers |
expose.agent.a2a.request_timeout | Rejected. 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_grace | Rejected. It would defer every question the instant it was asked |
llm.budget.call_timeout | Rejected. 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
| Stage | Examples |
|---|---|
| Parse | An 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 startup | fisk 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 |
| Run | A 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_clientsenv,headersandurl. A value incommandorargsis taken literally. A relativePATHresolves underroot_directory. api_key_fileand--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_fileand 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-telemetryputs 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.