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
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:
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.
- Identity fallback Explicit value, else the basename of
application_path, elsefisk-ai. - 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.
- Normalize global flags Trim, strip leading dashes so
--contextandcontextboth work, drop empties, de-duplicate. - Prepare the MCP block Validate
confirm_over_mcp, validate thebuiltinsallowlist, parse the tool timeout. - Prepare the a2a block The same limits pass, with its own path in every error message.
- Prepare the budget Reject negatives, then default max tokens to 200000, iterations to 50, and the call timeout to 120s.
- 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.
| Rule | Reason |
|---|---|
global_flags with no application_path is an error | Global 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 pattern | The name keys the NATS subjects |
A remote host alias must match the pattern too | It prefixes imported tool names |
A remote host exclude.tags filter is rejected outright | Discovery 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:
| Mode | Requires |
|---|---|
ModeAgent | llm.model always; identity and system_prompt unless also exposed over MCP; nats_context when remote_tools is set |
ModeMCP | nothing beyond the structural checks |
ModeServer | application_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_mcpmust beauto,always, orneverafter trimming and lowercasing. A typo must not silently select a weaker gate than intended.expose.agent.mcp.builtinsmay contain onlyknowledge_searchandknowledge_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_toolsrejects 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_timeoutmust 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.
| Setting | Precedence |
|---|---|
| MCP port and address | flag, then config, then built-in default |
| TUI | Config no_tui is an absolute veto. The flag can only turn the TUI off, never on |
--state-dir | Folded 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:
- Validate flag combinations
--resumewith--checkpoint,--resumewith a query,--namewithout--checkpoint, and--forcewithout--resumeare all refused before any work happens. - Resolve whether the run is checkpointed This changes the interrupt contract, so it is decided first.
- Parse in ModeAgent Then fold in
--state-dir. - Open the HTTP debug file The CLI owns it: mode 0600, removed then reopened with
O_EXCLto defeat a symlink planted at the fixed name. - Peek the session's chat flag when resuming So
--chatneed not be re-passed. - Pick a UI Chat with
--no-tuiis 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.
| Command | Mode | Purpose |
|---|---|---|
run | ModeAgent | Runs the agent. Owns the largest flag set |
session ls, show, rm | ModeMCP, or none | Inspects checkpointed journals. --config is optional |
info | ModeMCP | Explains a config without contacting a model |
knowledge and its thirteen subcommands | reads harness.knowledge | Builds and inspects the local index |
mcp | ModeMCP | Serves tools over MCP |
a2a | ModeServer | Serves tools to other agents over NATS |
discover | reads nats_context only | Prints 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_agentsis entirely unused. The field and its type are referenced nowhere outsideconfig.goand its test. Nothing reads them and nothing validates them, unlikeremote_tools, which is checked in every mode.remote_toolsis the working feature.A2ATransport()is a stub returning the constant"nats". Atransportconfig 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.ThinkingConfigis a one-field struct on purpose, so an effort knob can land without a breaking config change.Config.Harnesscarriesomitemptyon 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_timeoutis named to avoid colliding withllm.budget.call_timeout, which bounds a different unit of work.llm.budget.max_tokensis 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.