Configuration

A Fisk AI agent is one YAML file. The same file drives run, mcp, and a2a, and each command validates only the parts it needs. The design goal throughout is that an operator mistake fails at startup with a message naming the fix, never at the first tool call.

Where it lives

config is a single file, config.go. It is pure data with no IO beyond reading the file, and it imports no internal package. That is deliberate: config is the lowest layer, so main and every internal package can import it.

One file, three modes

agent.yamlliteral, no interpolationparse, prepare, validateunknown keys are fatalModeAgentrun: model and promptModeMCPmcp, info: tools onlyModeServera2a: app and nats
The mode selects which required fields apply. Structural checks run in all three.

ModeMCP is the most permissive: it serves tools and needs neither a prompt nor a model. That is exactly why fisk info parses in it. Requiring a model would reject a valid MCP-only config that info exists to inspect.

Loading is strict on purpose

Parsing is one call with three consequential options:

yaml.UnmarshalWithOptions(data, cfg, yaml.DisallowUnknownField(), yaml.UseJSONUnmarshaler())

DisallowUnknownField makes every unknown key a hard parse error, including a harness setting mistakenly left at the top level. That case has its own test.

UseJSONUnmarshaler makes the parser populate the json.RawMessage fields, harness.memory.options and harness.sessions.options, with canonical JSON. A backend can then decode its own sub-block against a typed schema regardless of the source format. It is also why an unknown key inside an options block fails just as loudly, only at store construction time rather than at parse time.

Every field carries both json and yaml tags even though only YAML is read today, and the parse path is routed through canonical JSON deliberately, so a JSON config or a schema-driven editor is a small step rather than a rewrite.

Defaulting and normalization

prepare() runs before validation, in a fixed order.

  1. Identity fallback Explicit value, else the basename of application_path, else fisk-ai.
  2. Normalize confirm tags Trim, drop empties, de-duplicate, preserve first-seen order. Trimming is a safety fix: a trailing space would silently fail to match a real command tag.
  3. Normalize global flags Trim, strip leading dashes so --context and context both work, drop empties, de-duplicate.
  4. Prepare the MCP block Validate confirm_over_mcp, validate the builtins allowlist, parse the tool timeout.
  5. Prepare the a2a block The same limits pass, with its own path in every error message.
  6. Prepare the budget Reject negatives, then default max tokens to 200000, iterations to 50, and the call timeout to 120s.
  7. Prepare embeddings Default the timeout to 30s, else parse it and require a positive value.

Duration strings always come in pairs: a string field from YAML and a parsed time.Duration twin tagged json:"-" and yaml:"-". Parsing happens once, in prepare.

Defaults that do not live in the config package

This matters when reading config.go and finding no default for something the documentation promises.

top_k
Defaults to 5 with a ceiling of 20, in internal/rag/store.go.
max_injected_tokens
Defaults to 6000, also in the rag store.
knowledge.directory
Defaults to knowledge/<identity>, resolved by the rag store.
memory directory
Defaults to memory/<identity>, resolved by the file backend.
max_output_tokens
Defaults to 8192, raised to 16384 when thinking is on, in internal/agent.
MCP port and address
Default to 8080 and 127.0.0.1, resolved in mcp_command.go.

Validation

Structural checks run in every mode. Mode-specific required fields come after.

RuleReason
global_flags with no application_path is an errorGlobal flags belong to the wrapped application and have nothing to attach to
A non-empty identity must match ^[a-zA-Z0-9_-]+$It doubles as a NATS queue group and appears in subjects, so whitespace, ., *, or > would form an invalid or wildcard-bearing subject
A remote tool host needs a name matching the same patternThe name keys the NATS subjects
A remote host alias must match the pattern tooIt prefixes imported tool names
A remote host exclude.tags filter is rejected outrightDiscovery carries no tags, so the exclude could never be honored and would silently leave an unwanted tool imported

The identity pattern is checked in every mode whenever the value is non-empty, not only where the field is required, because the value may have been derived from a binary basename carrying a dot or a space.

An include-by-tag on a remote host is treated more gently than an exclude-by-tag: the import path warns and ignores it rather than refusing, since over-inclusion is visible and under-exclusion is not.

Mode-specific requirements:

ModeRequires
ModeAgentllm.model always; identity and system_prompt unless also exposed over MCP; nats_context when remote_tools is set
ModeMCPnothing beyond the structural checks
ModeServerapplication_path and nats_context

A2A requires an application because no built-in declares a2a exposure, so an application-less a2a server would start with an empty tool set. The server itself can carry any tool kind; the requirement expires when a built-in first opts in.

Several rejections happen during prepare rather than validation, so they fire in all modes:

  • confirm_over_mcp must be auto, always, or never after trimming and lowercasing. A typo must not silently select a weaker gate than intended.
  • expose.agent.mcp.builtins may contain only knowledge_search and knowledge_enumerate, and the error names the accepted set and why the others are excluded. This is the selection, not the capability: the tool’s own declaration is the ceiling and this can only narrow it, and naming one knowledge tool never selects the other. A non-empty allowlist with knowledge disabled is also rejected, since there would be nothing to serve.
  • max_concurrent_tools rejects a negative value and anything above 1024. Zero is treated as unset rather than rejected, because an omitted YAML key unmarshals to zero and the server applies its own default.
  • tool_timeout must parse and be non-negative, and the error names which block it came from since MCP and a2a share the helper.

Overrides and precedence

There is no environment interpolation inside the YAML. The file is literal. Overrides are layered by the CLI.

SettingPrecedence
MCP port and addressflag, then config, then built-in default
TUIConfig no_tui is an absolute veto. The flag can only turn the TUI off, never on
--state-dirFolded into the config object after parsing, so it wins over a configured directory

--state-dir is the one flag written back into the config. Against a non-file session backend it is a hard error rather than a silently ignored flag.

Flag-or-environment bindings such as ANTHROPIC_API_KEY, FISK_AI_MCP_PORT, and FISK_AI_STORE_DIR never enter the config object at all. They travel alongside it into the agent’s options or are resolved in the command.

Where the config does name an environment variable, it names it rather than holding a value. api_key_env is a variable name, and CredentialEnvNames() returns those names so a tool subprocess’s environment can be scrubbed of them.

What identity silently controls

identity is more load-bearing than its one-line description suggests. It is the discovery name, the NATS queue group, so multiple agents sharing it share work, and the namespace for on-disk state: memory/<identity> and knowledge/<identity>.

Changing it orphans an existing memory store and knowledge index. Nothing warns about that, because nothing can tell an intentional rename from a typo.

From config to a running agent

fisk run does the following before agent.Run sees anything:

  1. Validate flag combinations --resume with --checkpoint, --resume with a query, --name without --checkpoint, and --force without --resume are all refused before any work happens.
  2. Resolve whether the run is checkpointed This changes the interrupt contract, so it is decided first.
  3. Parse in ModeAgent Then fold in --state-dir.
  4. Open the HTTP debug file The CLI owns it: mode 0600, removed then reopened with O_EXCL to defeat a symlink planted at the fixed name.
  5. Peek the session's chat flag when resuming So --chat need not be re-passed.
  6. Pick a UI Chat with --no-tui is refused loudly rather than silently degraded.

The naming split in the knowledge feature is intentional and worth stating rather than fixing. The YAML key and the user-facing noun are knowledge; the Go type is RAGConfig, the field is Harness.RAG, and the package is internal/rag. Knowledge is the feature, RAG is the technique. The CLI mirrors it: the command is knowledge with rag and k as aliases.

The command tree

main.go is 78 lines and loads no configuration. Every command parses the file itself, in the mode it needs.

CommandModePurpose
runModeAgentRuns the agent. Owns the largest flag set
session ls, show, rmModeMCP, or noneInspects checkpointed journals. --config is optional
infoModeMCPExplains a config without contacting a model
knowledge and its thirteen subcommandsreads harness.knowledgeBuilds and inspects the local index
mcpModeMCPServes tools over MCP
a2aModeServerServes tools to other agents over NATS
discoverreads nats_context onlyPrints a remote agent’s card

interruptContext is the shared one-shot contract: SIGINT plus SIGTERM, so a server stops cleanly under systemd or a container stop, and a second signal falls through to the default disposition. run deliberately does not use it, because it layers a graceful-suspend contract on top.

main.go blank-imports the file session backend so the session subcommands, which construct a store directly, are self-sufficient. The run path picks it up transitively through the agent package.

Reserved and unused

  • remote_agents is entirely unused. The field and its type are referenced nowhere outside config.go and its test. Nothing reads them and nothing validates them, unlike remote_tools, which is checked in every mode. remote_tools is the working feature.
  • A2ATransport() is a stub returning the constant "nats". A transport config field is deferred until a second transport exists, but the value is still routed through the transport registry, so adding one is a config field plus a blank import.
  • ThinkingConfig is a one-field struct on purpose, so an effort knob can land without a breaking config change.
  • Config.Harness carries omitempty on a non-pointer struct, which is a no-op for struct values. It is cosmetic.
  • MCP and a2a each get their own concurrency and timeout knobs rather than sharing one, because the two bound different trust boundaries: anything that can reach a TCP port, possibly on a non-loopback address, versus NATS peers. Both are config-only, with no flag or environment override.
  • tool_timeout is named to avoid colliding with llm.budget.call_timeout, which bounds a different unit of work.
  • llm.budget.max_tokens is a soft, deliberately over-counting cap. It sums uncached input, cache reads, cache writes, and output, so with prompt caching on it over-states dollar cost by design. A cost-weighted budget is named as separate future work. It is also soft in timing, since the total is checked after each call.
Next

The agent loop picks up where parsing ends, and turns the validated config into behavior.