Design

Design and internals documentation for Fisk AI. It complements the user-facing guides by explaining how the code is put together and why.

Start with the Code Map for a guided deep-dive: what the project is, how the packages layer, and how each subsystem works.

  • Code Map: A guided deep-dive into the Fisk AI codebase: architecture, subsystems, and the flows that connect them.
  • Development Roadmap: planned direction for the Fisk AI platform

Subsections of Design

Code Map

Fisk AI turns any fisk-based command-line application into a safety-first LLM agent. It introspects the application’s command tree, exposes the allowed commands as tools, and runs an agent loop against the Anthropic API that calls those tools to satisfy a prompt. This code map explains how that is built.

Snapshot

Generated 2026-07-16 against commit e92647e on branch main. Commits after this one may make parts of this map stale.

Create a Code Map for your code using the Choria Codemap Plugin for Claude Code

The mental model

There is one core and several faces. The core is the tool model: a fisk application is introspected once, its runnable leaf commands become named tools, and reserved tags plus include and exclude rules decide which of them an LLM may ever see. That same tool set is then consumed three ways. The run command wraps it in an agent loop that calls the model, runs the tools the model asks for, and feeds results back under a budget. The mcp command serves the tools over the Model Context Protocol for an external client. The a2a command serves them over a NATS-based agent-to-agent protocol. Safety is not a layer on top; it is built into the core, so every face inherits the same guardrails: commands run through exec with an argument vector rather than a shell, output is capped, credentials are stripped from the child environment, and a tagged command can require a human to approve it before it runs.

Fisk CLI appcommand treeTool modelcommands as toolsAgent loopMCP serverA2A / NATSAnthropic APIMCP clientPeer agents
One tool model, three faces. The agent loop is the only face that talks to the model; the dashed edge is the model's response feeding the next iteration.

What a run looks like

The default run face drives the loop until the model produces a final answer or a budget stops it. Only the answer reaches stdout; traces and the run summary go to stderr, so the output stays safe to pipe.

$ fisk-ai run --tool-output --no-tui 'how many consumers on the busiest stream?' -> stream_ls ok -> consumer_ls --stream ORDERS ok The ORDERS stream is busiest and has 4 consumers. run summary: llm_calls=3 tool_calls=2 tokens=4102/210 latency=3.9s

Explore

Next

Start with Architecture for the package layering, then follow the subsystem pages in menu order.

Subsections of Code Map

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.

Tools and Introspection

The tool model is the center of the whole system. Fisk AI never lets a model run arbitrary commands. It introspects a fisk application once, turns its runnable commands into named tools, and then decides which of those tools a model is even allowed to see.

Where it lives

internal/util: introspection and execution in fisk.go, translation to the Anthropic API in anthropic.go, the load pipeline in util.go. The agent.yaml schema is in config/config.go. The fisk-ai info command in info_command.go prints the resolved set without calling the model.

From a command tree to a tool list

ToolsForApp execs the target binary with --fisk-introspect and unmarshals the emitted fisk.ApplicationModel (fisk.go:696, fisk.go:714). ApplicationTools then walks the command tree with commandTools (fisk.go:558, fisk.go:584). The walk keeps only runnable leaf commands: a hidden command is skipped, and a grouping node with subcommands is recursed into but is not itself a tool. Every surviving leaf becomes one Tool bound to its fisk.CmdModel, the source of truth for the command’s schema and tags.

A tool’s name is its command path joined with underscores. The command auth user add becomes the tool auth_user_add (Tool.Name, fisk.go:103). That name is what the model addresses, what include and exclude rules match against, and what a remote import prefixes with its alias.

fisk binaryon diskleaf toolsrunnable cmdsstrip denyunconditionalselectinclude/excludetool paramsto the APIintrospect
The load pipeline in `util.LoadTools`. The deny strip runs before any rule, so a denied tool can never be selected back in.

Reserved tags

Three tags carry meaning to Fisk AI itself. The rest are free-form and can only be referenced by include and exclude rules. Tags are set in the fisk command definition, or in YAML for an App Builder application.

TagEffect
ai:denyNever exposed. Stripped before any include or exclude rule, so it can never be added back (fisk.go:638).
ai:no_deferAlways sent to the model directly rather than deferred behind tool search (anthropic.go:91).
ai:confirmRequires operator approval before the command runs. Covered in Safety and Human in the Loop.

Selecting tools

LoadTools is the pipeline (util.go:221). It always runs one filter pass first with a nil filter, which strips ai:deny and nothing else, so deny is enforced even when no rules are configured. Then, if include names any tools or tags, only matching tools are kept; if exclude names any, matching tools are dropped.

A rule’s tools entries are regular expressions matched against the tool name. Its tags entries are exact tags, where an empty string matches a tool with no tags at all. A tool matches a rule if any name regex matches or any tag is present (matchesFilter, fisk.go:666). Include and exclude can be combined: include ^cow but exclude ^cow_think.

Tool search deferral

Recent Anthropic models can search a large tool set on demand rather than receive every definition up front. Fisk AI uses this above a threshold. BuildToolParams counts local, remote, and built-in tools together; at ten or more (toolSearchThreshold, anthropic.go:21) it marks tools for deferred loading and appends the BM25 tool-search server tool. Below the threshold every tool is sent directly and no search tool is added.

A tool tagged ai:no_defer is always sent directly even in a deferred set, which keeps the handful of commands a model needs on most requests immediately available. Because the model discovers deferred tools by their description, ModelDescription appends a trailing Tags: ... line so a tag search can find them (fisk.go:126).

Load-bearing decision

Tool schemas are sent but deliberately not marked strict. Grammar mode caps a tool at 24 optional parameters, which a broad command tree exceeds. The schema still constrains the model; it is just not enforced as a grammar (anthropic.go:30).

Running a tool

Tool.Execute turns the model’s JSON arguments into a command line with t.Model.ArgsFromJSON, the trust boundary that bounds input to the command’s schema, then runs exec.CommandContext with the argument vector (fisk.go:449). No shell is involved, so model input can never be interpreted as a shell command. A non-zero exit is a normal result carrying the exit code, not an error, so the model can react to it. Output handling and credential stripping are detailed in Safety and Human in the Loop.

Next

Continue to The Agent Loop to see how these tools are called under a budget.

The Agent Loop

The agent loop is what fisk-ai run drives. It calls the Anthropic model, runs the tools the model asks for, feeds the results back, and repeats until the model gives a final answer or a budget stops it.

Where it lives

internal/agent: one-time setup in agent.go, the loop itself in runner.go, the reporting contract in events.go. The single model call with its per-call timeout is util.CallLLM in internal/util/llm.go.

Setup, then loop

agent.Run does all the one-time work before the loop starts (agent.go:119): load and validate tools, inject the built-in tools, import any remote tools, build the Anthropic tool params, construct the confirm gate, build the client, seed the conversation, compute the resume fingerprint, and open or resume the journal. It then constructs a runner and calls its loop. The runner splits its state in two: infrastructure that is rebuilt from config on every start or resume, and mutable conversation state that is resumable. That split is what makes a run suspendable.

How one iteration runs

The loop runs while the iteration count is below max_iterations (runner.go:368).

  1. Check for suspend Only at the loop boundary, before the iteration index is consumed, never mid-tool, so the conversation is always left coherent (`runner.go:384`).
  2. Call the model Under a per-call timeout derived from `call_timeout`. `util.CallLLM` wraps the single request in its own context (`llm.go:16`).
  3. Journal the turn The assistant response is appended to the conversation and journaled before any tool runs, so a crash mid-batch resumes without re-paying for the call (`runner.go:444`).
  4. Decide terminality A turn with no tool-use blocks, and not a paused turn, is the final answer and ends the run. Otherwise the tool calls are executed (`runner.go:470`).
  5. Run tools and feed back Each tool result is journaled as it completes, then all results are appended as one user message that becomes the next iteration's input (`runner.go:494`).
call modelunder timeouttool_use?final answerreturnrun toolsfeed results backnoyesloop
One iteration. The dashed edge feeds tool results back into the next call; the token budget is checked on that edge before any further tools run.

Budgets

Two different token caps apply. defaultMaxOutputTokens (8192, or 16384 with thinking) bounds a single response so a call stays under the non-streaming ceiling (agent.go:39). The configured llm.budget.max_tokens bounds the whole run. The run budget is a soft cap checked after each call but before that turn’s tools run, so an over-budget turn incurs no further tool side effects (runner.go:490). All four token tiers, input, output, cache read, and cache create, are summed so the cap measures total throughput.

max_iterations bounds the loop count and call_timeout bounds each call. The defaults are 200000 tokens, 50 iterations, and 120 seconds.

Load-bearing decision

The assistant turn is journaled before any tool executes, and each tool result is journaled the instant that tool finishes. This ordering is what gives the durability guarantee in Sessions and Resume: a clean suspend is exactly-once and a crash repeats at most one tool call.

Reporting, not rendering

The loop never draws anything. It emits typed callbacks through the Events interface, and the caller decides how they look. The same callbacks back both the line UI and the full-screen UI, which keeps the loop free of terminal concerns. Tool dispatch distinguishes local, remote, built-in, and memory tools so each is traced correctly.

Next

Continue to Safety and Human in the Loop for the guardrails around each tool call.

Safety and Human in the Loop

Safety is the reason Fisk AI exists. Two ideas carry it: a command runs with no shell and no credentials so it is bounded by construction, and a tagged command can require a human to approve it before it runs. Both default to the safe outcome.

Where it lives

internal/util: the confirm gate in confirm.go, the ask_human_* tools and terminal sanitization in builtin.go, the prompter contract in prompter.go and its line implementation in survey_prompter.go. Command execution safety is in fisk.go. The full-screen prompter is internal/tui/prompter.go.

A command is sandboxed by construction

When the loop runs a tool, Tool.Execute passes an argument vector to exec.CommandContext. No shell is involved, so model-supplied arguments can never be interpreted as shell syntax; t.Model.ArgsFromJSON is the trust boundary that bounds input to the command’s schema. The child gets no stdin, a ten-second wait delay after cancellation, an environment with credentials stripped, and its combined output capped.

model argsJSONargv execno shellraw outputstdout+stderrcap 64 KiBhead + tailchild envno secretsArgsFromJSON
A tool call becomes an argument vector, never a shell command. The child environment has its credentials removed and gains `LLMFORMAT=1`.

commandEnv removes five secret-bearing variables so a model-chosen command can never read the agent’s own credentials: ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_IDENTITY_TOKEN, ANTHROPIC_WEBHOOK_SIGNING_KEY, and ANTHROPIC_CUSTOM_HEADERS (fisk.go:511). It appends LLMFORMAT=1 so a fisk application can render output suited to a model rather than a terminal. Output is capped at 64 KiB, keeping the head and tail with a truncation marker, so a chatty command cannot flood the model’s context.

Load-bearing decision

The built-in tools run in-process with the agent’s own, unstripped environment. Their handlers must never hand that environment to a subprocess. Only the exec path through commandEnv is sanitized (builtin.go:38).

Two ways to put a human in the loop

The two mechanisms are distinct and address different needs.

ai:confirm
An application author gates a specific command. The operator must approve it before it runs; the command itself is unchanged. Always active, no configuration flag.
human_in_the_loop
A configuration flag that gives the model three `ask_human_*` tools so it can ask the operator a question it chose to ask.

Reach for ai:confirm when a normal command should run only with the operator’s say-so. Reach for human_in_the_loop when the model should decide when to check in. The harness.confirm_tags key extends the gate to any tag an application already uses, for example impact:rw.

The confirm gate defaults to deny

When the model calls a gated command, the gate runs the checks in a fixed order, and every failure is a denial.

  1. Missing parameters first A structurally invalid call is rejected before the gate, so the operator is never asked to approve a broken command (`runner.go:608`).
  2. Session-allow short-circuit If the operator earlier chose "allow for the session" for this command, it runs without asking again.
  3. No terminal, no run Without an interactive terminal, or with a canceled context, the gate denies before any prompt is shown (`confirm.go:76`).
  4. Prompt the operator The sanitized full command line is shown. Any prompter error, an interrupt, an end-of-input, or an Escape, is a denial.

A denial is returned to the model as an authoritative, non-error result whose reason ends with a note not to retry, so the model reasons about the refusal rather than routing around a tool failure. An “allow for the session” answer is remembered by command name, regardless of arguments, and lasts only for that run.

The ask_human tools

When human_in_the_loop is enabled the model gets three tools: ask_human_confirm (yes or no), ask_human_select (choose one option), and ask_human_input (a free-text value). Each denies by default: a missing terminal, an interrupt, or an end-of-input yields a negative answer rather than a guess. Each renders on stderr so a piped final answer stays clean, and the model-supplied text is stripped of terminal control sequences first, before any truncation, so a cut can never leave a dangling escape.

Load-bearing decision

The confirm gate and the ask_human_* tools are agent-loop only and are never exposed over MCP or A2A, where there is no operator to answer. Over MCP a confirm-tagged command is requested through elicitation instead, which a client may auto-approve, so ai:deny is the only way to keep a command off a served surface entirely. See Interoperability.

Next

Continue to Sessions and Resume for how a run survives a suspend or a crash.

Sessions and Resume

By default a run lives only in memory. With --checkpoint it is journaled to disk as an append-only record stream, so it can be suspended and resumed later, in a fresh process or on another machine. The journal is also the foundation for the durability guarantees the agent loop relies on.

Where it lives

internal/runstate: the record schema in record.go, the pure fold in state.go, the storage interfaces in store.go, the file backend in filestore.go, the resume guard in fingerprint.go, and locking in lock_unix.go. The transcript replay is resume_replay.go; the session ls|show|rm subcommands are in session_command.go.

A run is a record stream

A run is an ordered sequence of self-describing records. A MetaRecord is always first at sequence one. Each model turn is an AssistantRecord stored verbatim, each tool result a ToolResultRecord, each interactive follow-up a UserRecord, and the run ends with a single TerminalRecord carrying its reason. The schema is at Version = 2; a reader accepts any older version but refuses a newer one, because every version only adds records or optional fields.

Fold replays a record set into a resumable RunState with no IO (state.go:93). The state carries the committed conversation prefix, cumulative counters derived from the journal so they can never drift, the next iteration number, and any in-flight tool batch. It deliberately holds no client, credentials, or config; those are rebuilt on resume.

Metaseq 1AssistantLLM turnResulttoolResulttoolTerminalreasonFoldpure, no IORunStatemessages + counters
Records append left to right; resume folds them into `RunState`. Records are fsynced, so only the last line can ever be torn.

Suspend, crash, and resume

The ordering within a turn is deliberate: the assistant turn is journaled before any tool runs, and each tool result is journaled the instant that tool completes. That gives two semantics.

  • A clean suspend is exactly-once. The terminal record marks the boundary, and resume never repeats a tool call or a model call.
  • A crash resumes from the last recorded event, so at most one tool call is repeated: the one in flight when the process died, whose side effect completed but whose result never reached disk. On resume, completePending reuses journaled results for answered tools and re-runs only the unanswered ones.

Every append is a write followed by an fsync, and the first append also fsyncs the directory, so a new run survives a crash. Reading tolerates exactly one torn tail line, which is safe because on an append-only fsynced file only the last record can be incomplete.

The resume guard

Continuing a conversation against a changed model, prompt, or tool set can be incoherent, so a resume is refused when the configuration no longer matches. The fingerprint covers the model, a hash of the system prompt, a hash of the tool set, the thinking mode, and both budgets (fingerprint.go:21). Hashes are stored, never the prompt itself, so nothing leaks. A mismatch names exactly what changed and refuses unless --force is given.

Load-bearing decision

Prompt caching, the memory index, and the resume reminder are all appended after the fingerprint is computed, so none of them can perturb the comparison. Memory drift between suspend and resume never blocks a resume; memory is data, not configuration.

Storage and locking

Sessions live under the XDG state directory, $XDG_STATE_HOME/fisk-ai/runs or ~/.local/state/fisk-ai/runs, never the working directory, so runs do not leak into repositories. Each run is a <id>.json journal plus an <id>.lock file; the directory is 0700 and journals are 0600. On unix a per-run advisory flock held for the life of an open journal prevents two processes appending to the same run, and the kernel releases it on exit so a crash leaves no stale lock. Load and List do not lock, since they only read.

Naming

The user-facing term is “session” (session ls, --name), while the internal package and types say “run” (RunID, RunState, runstate). They are the same value.

Next

Continue to Memory for durable notes that persist across separate runs.

Memory

Memory gives the model a small key/value store that persists across runs, so it can keep durable notes and pick them up next time rather than rediscovering them. It is opt-in, agent-mode only, and never exposed over MCP.

Where it lives

internal/memory: the backend-agnostic Store interface and factory in store.go, the file backend in file.go, the on-disk format in frontmatter.go, and key validation in key.go. The four model-facing tools and the system-prompt index are in internal/util/builtin_memory.go.

Four tools over one interface

When memory is enabled the model gets four tools: memory_list returns keys and descriptions, memory_read fetches one entry, memory_write saves an entry, and memory_delete removes one. All four go through the Store interface, so the storage backend is pluggable behind them.

memory_listmemory_readmemory_writememory_deletememory.Storeone interfacefile backendone .md per keyunder memory/id
The tools depend only on the interface. The file backend is the one implementation today.

Keys, files, and races

A key is letters, digits, and . _ = -, with no leading or trailing dot and no .. (key.go:25). The slash that a NATS KV key also permits is deliberately excluded, so a key maps one-to-one to a flat filename with no separator to traverse. ValidateKey guards every operation and filters directory entries, which makes it a path-traversal defense as well as a format rule.

The file backend stores each entry as a markdown file with a YAML frontmatter description under memory/<identity>, where the identity defaults to the application binary’s base name. Two agents pointed at the same directory share a memory; the default keeps each separate. Create and overwrite use the filesystem for atomicity: a create links a staged temp file into place and fails if the name exists, giving a race-safe create guard, while an overwrite renames over the target. Content is capped at 64 KiB, sized to what a future NATS KV backend would accept.

The start-of-run index

At the start of a run, if the index is enabled, the stored keys and descriptions are injected into the system prompt inside a <memory-index> block, so the model knows what it has saved. memory_list is the live view during the run. The index is turned off with no_index.

Load-bearing decision

Memory is untrusted data, not instructions. The system note, the index framing, and the description sanitization all reinforce that a stored value is data the model saved, never an instruction to follow. A read opens with O_NOFOLLOW and rejects any non-regular file, so a planted symlink cannot redirect a read.

Next

Continue to Knowledge and RAG for the other opt-in, agent-only data store: a searchable document index.

Knowledge and RAG

Knowledge gives the model one tool, knowledge_search, over a locally built index of the operator’s own markdown and text files, so it can ground an answer in project documentation instead of its training data. The whole retrieval system, index and search, lives in the one fisk-ai binary and one process. It is opt-in and off by default, agent-mode only, and served over MCP only through an explicit allowlist, the same posture as Memory.

Where it lives

internal/rag: the SQLite store in store.go, chunking in chunk.go, indexing in index.go, hybrid search in search.go, the embeddings client in embed.go, health checks in doctor.go, and the cross-process write lock in lock_unix.go and lock_windows.go. The model-facing knowledge_search tool is in internal/util/builtin_rag.go; the fisk-ai knowledge CLI is in rag_command.go; the harness.knowledge schema is in config/config.go.

A search always runs the lexical tier and, when the vector tier is on, runs it too and fuses the two. The lexical tier is an FTS5/BM25 full-text index; it is always active when knowledge is enabled and needs no model, no server, and no per-query cost. The vector tier is a separate opt-in: adding an embeddings block turns it on, each chunk is embedded through a local OpenAI-compatible server, and a query fuses the lexical and vector rankings with Reciprocal Rank Fusion. Lexical matches on shared words; vector matches on meaning, so a query finds the right section even when it shares no keywords with it.

Search runs FTS5 first (ftsSearch, search.go:104), then, if an embedder is configured, embeds and normalizes the query and runs a KNN over the sqlite-vec table (vecSearch, search.go:123). Each retriever over-fetches searchFanout = 50 candidates (search.go:25), rrf fuses the two ranked lists with the constant rrfK = 60 (search.go:20, search.go:286), and the top_k survivors are hydrated into cited hits. RRF fuses by rank, not score, so the incomparable scales of BM25 and L2 distance never need normalizing against each other.

querytext termsFTS5 / BM25lexical, always onvector KNNopt-in, sqlite-vecRRF fuseby rank, K=60top_k hitseach citedembeddings unreachable: lexical only
Every query runs FTS5. When the vector tier is on, KNN runs too and RRF fuses both by rank. If the embeddings server is unreachable the query degrades to lexical and says so.

Building the index

knowledge index is the single writer. It is incremental and per-file: a file whose content hash is unchanged is skipped, a changed file is re-chunked, and a walk of a full configured root reconciles deletions. Only .md, .markdown, .txt, and .text files are walked, and the store directory and any memory/ directory are always excluded.

  1. Walk and hash Each eligible file's SHA-256 is compared to the stored document row; an unchanged hash skips the file, a new or changed one is queued (`ingestOne`, `index.go`).
  2. Chunk `ChunkDocument` splits on ATX headings and packs blocks to roughly 1200 bytes, keeping fenced code blocks whole and folding the heading breadcrumb into each chunk (`chunk.go`).
  3. Embed outside the transaction When the vector tier is on, `EmbedDocuments` runs and each vector is L2-normalized (`index.go:274`, `index.go:283`), before any write transaction opens.
  4. Upsert in one short transaction The document, its chunks, and their vectors are written in a single `BeginTx` block (`index.go:287`); FTS5 triggers keep the full-text table in sync, and a foreign-key cascade clears old chunks first.

The embed step sits deliberately outside the transaction so a slow or hung embeddings call never holds the single writer slot open. The writer is serialized across processes by an advisory flock on Unix (lock_unix.go) and an O_CREATE|O_EXCL lock file on Windows (lock_windows.go), since WAL mode lets multiple processes open the file and MaxOpenConns(1) alone cannot serialize them.

The knowledge_search tool

When knowledge is enabled the agent run path opens the store read-only and builds one built-in tool (RAGTools, builtin_rag.go:38), tracked in its own slice beside the memory tools (agent.go:189). A start-of-run system note tells the model the tool exists and to consult it before answering project-specific questions (RAGSystemNote, builtin_rag.go:50). The tool takes a query and an optional top_k; the effective count is min(requested or configured, 20), and the total returned text is capped at max_injected_tokens by capHits at roughly four characters per token (builtin_rag.go:177).

Each result carries a citation token of the form relpath#ordinal plus the section’s heading path. The same token is printed by knowledge search and knowledge sources and accepted verbatim by knowledge show, so a hit resolves back to its full text. When no index exists yet the tool returns a soft index_not_built status naming the fix rather than failing the run, so a missing index never bricks agent startup.

Over MCP the tool is served only when it is allowlisted in expose.agent.mcp.builtins (MCPExposesKnowledgeSearch, config.go:726). knowledge_search is the one exposable built-in because it is read-only and needs no operator prompt; the memory and human-in-the-loop tools stay agent-only. The MCP path opens the same read-only store and closes it after the server stops (mcp_command.go:109).

Load-bearing decision

Transient and permanent failures are handled asymmetrically. An embeddings server that is unreachable at query time degrades to lexical and sets Degraded with a reason (search.go:120), so a search never fails just because the model is offline. A dimension, model, or prefix mismatch instead fails loud and demands a --reindex, because a silently wrong vector identity would return wrong rankings. An unreachable server at index time also fails loud, so an index asked to be semantic is never quietly built lexical-only.

Caveat

The index holds the verbatim text of every indexed document, unencrypted on disk. The file and its -wal and -shm sidecars are created 0600 inside a 0700 directory, opened with O_NOFOLLOW on Unix, and retrieved chunks are framed as untrusted reference data, not instructions. This is the memory posture: do not index secrets, and bind localhost or add authentication before exposing knowledge_search over MCP.

Next

Continue to The Terminal UI for how a run is presented to an operator.

The Terminal UI

The default run presentation is a full-screen terminal UI built on tview and tcell. It shows the run as it happens, folds thinking and tool output on a keypress, and can keep a chat bar open to continue after a turn. The same widget model backs the read-only transcript viewer.

Where it lives

internal/tui: the shared widget model and transcript viewer in viewer.go, the live-run wrapper in live.go, the native prompts in prompter.go, and the identity card in splash.go. The seam from the run loop is tcellEvents in run_tui_events.go.

Two goroutines, one writer

A live run has two goroutines. One runs agent.Run and emits Events; the other is the tview event loop that owns the screen. The run goroutine never touches widgets directly. Every cross-goroutine mutation is marshaled onto the tview loop with QueueUpdateDraw, so view state has a single writer. tcellEvents is the only producer, and it always goes through Live.Append.

agent looprun goroutinetcellEventsmaps to Linesviewertview loopterminalalt-screenEventsQueueUpdateDrawPrompter
Events flow down onto the single-writer tview loop; the prompter carries an operator's answer back up to the blocked run goroutine.

Rendering and hot-keys

Each Events callback maps to one or more Line values and appends them. Narration is rendered as markdown, then escaped; everything else is sanitized and wrapped in trusted per-kind color tags, so model text can never open a color or region tag. Tool output starts collapsed. The z and Z keys toggle folding of thinking and tool output as a global view mode, since a flat text view has no per-block cursor. The view tails the run like tail -f, re-arming follow when a scroll reaches the bottom, and ? opens interactive help.

The chat bar

With --chat, an input row is added above the status bar. At each turn boundary the agent calls NextPrompt, which activates the field and blocks the run goroutine until the operator submits or leaves. Enter sends, Alt-Enter inserts a newline, Ctrl-D ends cleanly, and Ctrl-C aborts. Slash commands like /clear and /restart are resolved before the text is sent.

Keeping stdout pipeable

The full-screen UI collapses answers, narration, and traces into one viewport, so it must protect the alt-screen. muzzleStderr redirects os.Stderr into a buffer for the whole run so library logging cannot corrupt the display, then flushes it to the restored terminal on exit. The raw answer, warnings, and rotated-session ids are captured as they arrive and re-printed to real stdout and stderr after teardown, so a piped answer still lands on stdout exactly as the line UI would deliver it.

Load-bearing decision

When a run blocks on an operator decision, the prompter rings the terminal bell and recolors the status bar amber, unless no_bell is set. It rings only on the block transition, not while simply awaiting chat input, so an unattended run is noticed the moment it needs a human.

Next

Continue to Interoperability for serving the same tools to other clients and agents.

Interoperability: MCP and A2A

The same tool model that backs the agent loop can be served two other ways: over the Model Context Protocol for a client like Claude Desktop, and over a NATS-based agent-to-agent protocol for peer agents. A run can also import a peer’s tools and present them to the model as if they were local.

Where it lives

internal/mcpserver serves over MCP. The a2a package holds the transport-agnostic protocol types; internal/a2anats binds them to NATS. internal/remotetools is the import-policy layer. The commands are mcp_command.go, a2a_command.go, and discover_command.go.

Serving over MCP

mcp serves util.ServedTools, the loaded set narrowed by expose.agent.tools, over streamable HTTP. The port comes from --port, then the config, then a default of 8080. Each tool keeps its fisk input schema. A tool with a name the protocol rejects is skipped with a warning.

A confirm-tagged command is realized over MCP as an elicitation that asks the client for an approve boolean, driven by a confirm mode: auto asks clients that can elicit and runs others ungated with a warning, always refuses a client that cannot elicit, and never delegates approval to the client’s own UI. The gate fails closed on anything that is not an explicit accept.

Load-bearing decision

The human-in-the-loop and memory tools are never exposed over MCP or A2A. They are built-in tools appended only inside an agent run, so they are structurally absent from the served set; no filter could add them back. Because MCP confirmation is a request a client may auto-approve, ai:deny is the only way to keep a command off a served surface entirely.

A2A over NATS

The a2a package models the protocol as self-describing messages. Every message embeds a Header carrying its own framing, so a captured message is fully decodable without the transport. The NATS binding never infers meaning from the subject; it decodes the header’s protocol id and dispatches on that. The subjects exist only as permission seams, which is what lets the same message bodies move to another transport later without change.

consumera2anats.Clientdiscovery.IDtool.IDA2A serverexposed toolsDiscoverInvokeToolreply via inbox
Two request-reply round-trips over `choria.fisk-ai.discovery.` and `choria.fisk-ai.tool.`. Replies always return on the NATS-supplied inbox, never a subject from the body.

Both servers bound an un-budgeted caller: a shared semaphore caps in-flight calls at two and a per-call timeout defaults to thirty seconds. An execution failure is always an in-band error result, never a transport error, and a non-zero command exit is a successful result carrying the exit code, so the caller can reason about either.

Load-bearing decision

A served A2A agent has no operator, so it drops any confirm-gated tool outright rather than serving it ungated. This is the stricter analogue of the MCP behavior, and the same advice applies: use ai:deny to suppress a command entirely.

Importing a peer’s tools

A run with configured remote hosts discovers each one, filters by name, and assigns final model-facing names. A bare name is prefixed with the host’s alias when a local tool already holds it or when more than one host exposes it, decided over the whole set so runs are reproducible. A residual collision fails the run closed. The imported schema is untrusted and must parse as a JSON object. At call time a remote tool is invoked over NATS and its reply is mapped back to the same result shape a local tool produces, so the model cannot tell remote from local. Import is strict for a run, since the prompt may depend on those tools, but best-effort for info, which still shows local tools if a host is unreachable.

Reserved and planned

The streaming task flow, the Event, Result, Cancel, and Ack messages with their event blocks, is fully modeled and schema-validated but not yet carried by the NATS binding, which transports only discovery and direct tool calls. Wrapping the same message bodies in the Choria Protocol, with its authentication and authorization, is the planned second binding; the subject-contract design exists so that binding needs no change here.

Next

Continue to Reference and Map for the command surface, a source-file map, and a glossary.

Reference and Map

A quick index into the codebase: what each command does, where each package lives, the types worth knowing, and the vocabulary the rest of this map uses.

Command surface

main.go registers seven commands on the fisk-ai root. Each is defined in its own *_command.go file.

CommandWhat it doesFile
runDrives the agent loop against a prompt; the default face. Flags include --checkpoint, --resume, --chat, --no-tui, --tool-output.run_command.go
sessionLists, shows, and removes checkpointed sessions (ls, show, rm); show --transcript opens the viewer.session_command.go
infoPrints the resolved tool table, gated commands, and prompt without calling the model.info_command.go
knowledgeBuilds and inspects the local RAG index behind knowledge_search (index, search, show, sources, rm, reset, doctor, stats); aliased rag and k.rag_command.go
mcpServes the tools over MCP for an external client.mcp_command.go
a2aServes the tools over NATS for peer agents.a2a_command.go
discoverSends an A2A discovery request and prints a peer’s agent card.discover_command.go

Source-file map

PackageResponsibilityKey files
main (root)CLI wiring, flag parsing, mode and UI selection, signal contract.main.go, run_command.go, run_events.go, run_tui_events.go, resume_replay.go, rag_command.go
configThe single agent.yaml schema, mode-based validation, accessors.config.go
internal/utilThe shared core: introspection, tool params, built-ins, confirm gate, prompter, LLM call, stats.fisk.go, anthropic.go, builtin.go, builtin_memory.go, builtin_rag.go, confirm.go, prompter.go, llm.go
internal/agentThe agentic loop: setup, iteration, events.agent.go, runner.go, events.go
internal/runstateDurable sessions: records, fold, store, fingerprint, locking.record.go, state.go, store.go, filestore.go, fingerprint.go
internal/memoryThe pluggable memory store and file backend.store.go, file.go, frontmatter.go, key.go
internal/ragThe local RAG index: SQLite store, chunking, hybrid search, embeddings, doctor, write lock.store.go, chunk.go, index.go, search.go, embed.go, doctor.go, vec.go, lock_unix.go
internal/tuiThe full-screen runner and transcript viewer.viewer.go, live.go, prompter.go, splash.go
internal/mcpserverServing tools over MCP.mcpserver.go
a2aTransport-agnostic A2A protocol types.messages.go, block.go, header.go, types.go, schemas.go
internal/a2anatsThe NATS binding for A2A.client.go, server.go, a2anats.go, header.go
internal/remotetoolsImport policy for tools pulled from a peer.remotetools.go

Key types

TypeRoleExplained in
util.ToolA single introspected command as a tool: path, model, schema, tags.Tools and Introspection
config.ConfigThe parsed agent.yaml for any mode.Architecture
agent.runnerThe loop state, split into rebuilt infrastructure and resumable state.The Agent Loop
util.ConfirmGateThe default-deny approval enforcement for gated commands.Safety and Human in the Loop
runstate.RunStateThe folded, resumable state of a run.Sessions and Resume
runstate.FingerprintThe configuration hash that guards a resume.Sessions and Resume
memory.StoreThe pluggable key/value backend interface.Memory
rag.StoreThe SQLite-backed knowledge index: chunk, embed, hybrid search.Knowledge and RAG
tui.LiveThe full-screen live-run controller.The Terminal UI
a2a.HeaderThe self-describing framing on every A2A message.Interoperability

Glossary

Tool
A single runnable fisk leaf command exposed to the model, named by its command path joined with underscores.
Introspection
Running a fisk binary with `--fisk-introspect` to read its command tree as a model.
Deferral
Sending tool definitions on demand through the tool-search tool once the set reaches ten tools, rather than all up front.
Confirm gate
The default-deny check that makes an operator approve a command tagged `ai:confirm` or a `confirm_tags` tag before it runs.
HITL
Human in the loop: the `ask_human_*` tools the model can call to ask the operator a question.
Journal
The append-only record stream a checkpointed run writes to disk, event by event.
Fold
Replaying a record stream into a resumable `RunState` with no IO.
Fingerprint
A hash of model, prompt, tool set, thinking mode, and budgets that must match for a resume to proceed.
Identity
An agent's logical name, defaulting to the binary base name, used for the memory directory and the A2A subjects.
Elicitation
The MCP mechanism by which a served command asks the calling client to approve it.
Agent card
An A2A agent's self-description: name, version, protocols, and tools.
RAG
Retrieval-augmented generation: searching a local document index and feeding the matches to the model as grounding.
Chunk
A heading-delimited, size-packed slice of a document, the unit that is indexed, ranked, and cited.
Lexical tier
The always-on FTS5/BM25 full-text search over chunks; needs no model or server.
Vector tier
The opt-in semantic search: chunk embeddings in sqlite-vec, matched by nearest neighbor.
RRF
Reciprocal Rank Fusion: combining the lexical and vector rankings by rank rather than score, with constant K=60.
Citation
A `relpath#ordinal` token naming a chunk, resolvable back to full text by `knowledge show`.
Back to the start

Return to the Code Map overview for the system-at-a-glance diagram and the mental model.

Development Roadmap

Fisk AI aims to become a platform for building AI-powered applications in the operations space, closely integrated with Choria.

In large environments where Choria manages hundreds of thousands of machines, teams need to build AI agents that operate autonomously. These agents must cooperate with each other. A Redis team’s Outage Triage Agent must be able to communicate with a Monitoring team’s Metrics Agent to assist with its duties.

Cooperation across a Choria network requires Identity, Authorization, and Auditing. The Choria Protocol already provides these features. Fisk AI will build an Agent-to-Agent (A2A) system on top of the Choria Protocol.

The current focus is the day-to-day operator who automates tasks with LLMs on a local workstation.

Roadmap

  • A fisk-ai native RAG database that integrates with local LLMs to create embeddings. The initial scope is Markdown files, allowing local knowledge bases that agents can interact with. These databases can also be exposed to systems like Claude Code.
  • Choria Protocol A2A system
    • Identity
    • Auditing
    • Authorization
    • Tool sharing using Choria Services
  • NATS Server and Choria Broker integration
    • Memory in Key-Value stores
    • Sessions in Streams
    • Agent configuration in Key-Value stores
  • Work queue support for ingesting data and completing tasks