Architecture

Fisk AI is a single Go module, github.com/choria-io/fisk-ai, built on Go 1.26. The code separates cleanly into a thin command layer, three drivers that each expose the tools a different way, a shared core, and a persistence and protocol tier underneath. A handful of interface seams keep those tiers independent.

The layers

The top layer is package main at the repository root: main.go registers the subcommands and each *_command.go file owns one of them. It does flag wiring, mode selection, the signal and suspend contract, and the choice between the full-screen and line UIs. It holds no run logic of its own.

Below it are three drivers. internal/agent runs the agentic loop for run. internal/mcpserver serves the tools over MCP for mcp. internal/a2anats serves them over NATS for a2a. All three consume the same tool set from the shared core.

The core is internal/util plus config. util owns command-tree introspection, translation of tools into Anthropic API parameters, the built-in tools, the confirm gate, the prompter contract, the model call, and run statistics. config is the single agent.yaml schema and its mode-based validation.

The bottom tier is durable state and protocol types: internal/runstate journals a run, internal/memory persists model notes, internal/rag holds the SQLite knowledge index behind the knowledge_search tool, and the a2a package holds the transport-agnostic protocol messages that internal/a2anats binds to NATS. internal/remotetools sits beside the core as the import-policy layer for tools pulled from a peer agent.

Like memory, internal/rag is reached only through a built-in tool in the core: internal/util/builtin_rag.go opens a rag.Store and wraps it as knowledge_search. The agent and MCP drivers open the store read-only while the knowledge command is its single writer, so an index can be rebuilt while an agent runs. It is the one package in this tier that reaches an external system of its own, an optional local embeddings server, and only when the vector tier is on.

CLI commands (package main)run, session, info, knowledge, mcp, a2a, discoverDriversinternal/agent, internal/mcpserver, internal/a2anatsCore: internal/util + configintrospection, tool params, built-ins, confirm gate, prompterPersistence and protocolinternal/runstate, internal/memory, internal/rag, a2aExternal systemsfisk binaries (exec), Anthropic API, NATS, embeddings server
Dependencies point down. The three drivers share the core; nothing in the core reaches back up into a driver or a UI.

The seams that keep it composable

The tiers stay independent because the boundaries between them are narrow interfaces, not concrete types. Each one lets a driver swap an implementation without the core knowing.

Events
`internal/agent/events.go`. The agent decides what happened; the caller decides how it looks. `cliEvents` renders line output, `tcellEvents` drives the full-screen UI, both from the same callbacks.
Prompter
`internal/util/prompter.go`. The only path allowed to read the terminal. A line implementation (`survey_prompter.go`) and a full-screen one (`internal/tui/prompter.go`) satisfy it; deny-by-default lives at the caller, never in the prompter.
Store / Journal
`internal/runstate/store.go`. A run is an append-only record stream. The file backend is the only one today; the interface is shaped for a JetStream backend next. `Fold` turns records into resumable state with no IO.
memory.Store
`internal/memory/store.go`. A pluggable key/value store; the `file` backend exists, a NATS KV backend is the planned second.
rag.Embedder
`internal/rag/embed.go`. The knowledge vector-tier seam; the OpenAI-compatible client is the only implementation, tests mock it, and a nil embedder is the lexical-only path, so the vector tier is fully optional.
RemoteInvoker
`internal/util/remote.go`. A one-method interface so `util` depends only on `a2a` types, not the NATS binding, which avoids an import cycle and lets tests supply a fake.

One configuration, three modes

A single agent.yaml drives all three faces. config.ParseConfigForMode validates it against ModeAgent, ModeMCP, or ModeServer, each requiring a different subset of fields. The info command deliberately parses with the most lenient mode so it can inspect a config written for any face.

How a run composes

The run path threads every tier together in a fixed order.

  1. Parse and select tools `main` parses the config, then `util.LoadTools` introspects the fisk binary, strips `ai:deny`, and applies include and exclude rules.
  2. Set up the run `agent.Run` injects the built-in tools, imports any remote tools, builds the Anthropic tool params, constructs the confirm gate, and opens or resumes the journal.
  3. Drive the loop The `runner` calls the model, runs the tools it asks for through `util.ExecuteToolUse` behind the confirm gate, and journals each event as it happens.
  4. Surface it Every step is reported through `Events` to whichever UI is active, and the operator answers gates through the `Prompter`.
Load-bearing decision

The core never imports a driver or a UI. internal/util and config depend downward only. This is what lets the same tool model and the same safety guarantees back the agent loop, the MCP server, and the A2A server without duplication.

Next

Continue to Tools and Introspection to see how a command tree becomes the tool model at the center of this diagram.