Introduction

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

No glue code. If your CLI is built with Fisk, Fisk AI can turn it into a purpose-built agentic harness.

The main focus is on safety and determinism. Every feature is carefully considered and designed. What sets this harness apart is what it does not have rather than a long list of features.

It is designed in particular to complement Choria App Builder. App Builder lets you define a command line application declaratively in YAML, and because it is built on fisk, any App Builder application can be introspected and driven by fisk-ai. Together they let you define a strict, purpose-built set of tools in a YAML file and expose exactly those to an agent, without writing or compiling any code: App Builder describes the commands, fisk-ai’s configuration selects which of them the agent may use and how it should behave.

  • Small and Focused
    Deliberately restricted AI Harness to create safe, deterministic, AI Agents for Operations use
  • Safety first
    Agents can only interact with the tools provided. Using tags in the tool the Harness provides Human-in-the-Loop safety guardrails.
  • Utilities from Prose

    Supports creating Shell hosted utilities that have Agentic abilities and zero code - describe a flow, supply the tools and have it dynamically react

    Built-in tools for HITL and Memory complement those from Fisk.

  • Supports Local Models
    Host local models using ollama, llama.cpp, LM Studio and any other provider that supports the Anthropic API.
  • Single binary RAG

    Fisk AI includes a zero dependency, single binary, RAG system to create local knowledge bases for your Markdown data.

    Zero to full-text and vector search in under 1 minute for 1000s of Markdown files.

  • Loves App Builder

    Fisk AI is tailor-made to create AI Agents using just YAML files and utilities you already have by targeting App Builder as tool provider

    App Builder can create tools with strict guardrails, input validation and integration with secret providers like 1Password.

    Deterministic AI Harnesses are only a few YAML files away.

Use cases

I’ve used this with success in numerous problem areas:

  • Pull request review - do not want to give the LLM access to gh command as it will try to do a lot of things it is not supposed to. So I wrap gh with App Builder giving it commands such as “abt pr triage” which will apply the correct label
  • Built a DMARC email parsing system, do not want to give it shell access, turned a set of SKILLs into a standalone agent with just the tools it needs, no more randomly calling whatever the LLM wants. Once while performing this task Claude tried Bashisms on my Zsh and did rm -rf /, now that is impossible
  • Created various MCP servers to plug into Claude Code with strict control over how the tools are called
  • Tool to interpret GitHub repository stats - being able to just ask questions to interpret the data without fear of complex Bash callouts really helps
  • Drives complex testing scenarios against an API-driven Cluster Manager
  • Local knowledge base indexing Open Source project websites for local RAG based discussions

In all these cases the best solution is to apply understanding and language interpretation to the problem, but doing so safely and repeatedly from within Claude Code is difficult because that favors running Bash commands - and not always the same ones to solve the same problem.

Wrapping CLI tools like gh using App Builder and then only giving it these deterministic tools means we can get much better outcomes from LLM based utilities.

Shell example

Here we use the nats command line utility to create a Stream Management Agent.

# agent.yaml

# Command to introspect and expose as an agent
application_path: /usr/bin/nats

include:
  # Include the entire Stream and Consumer command set nothing else
  tools:
    - ^stream
    - ^consumer

harness:
  # Allow the LLM to prompt us for information if needed
  human_in_the_loop:
    enabled: true

  # Map nats command tags of impact to HITL prompts - any
  # command that changes the system requires human approval
  confirm_tags: [impact:rw]

llm:
  model: claude-haiku-4-5-20251001
  budget:
    max_tokens: 100000
    max_iterations: 50

system_prompt: |
  You manage NATS JetStream Streams using tools.

  Assist users with questions related to Streams and Consumers in their JetStream account.

Above we create an agent with various Stream and Consumer management utilities as tools, here we use it on the CLI:

We can now prompt this agent knowing it can only interact with these nats commands as tools.

How many consumers does the biggest stream (by messages) have? Show their names and when last they had activity

Subsections of Introduction

Agents

The main feature of Fisk AI is creating AI agents from CLI tools written with Fisk.

Any tool built with Fisk, such as the nats or choria CLI, or an application made with Choria Application Builder, can be turned into an AI agent.

Fisk AI creates capable systems that use the abilities LLMs have, such as reasoning and text interpretation, in a safe and deterministic manner.

Building an agent resembles building a CLI tool: describe the goals, give broad guidance, supply tools to interact with the world deterministically, then run it on a shell like any other utility.

Installation

On a Mac you can install fisk-ai using homebrew:

brew tap choria-io/tap
brew install choria-io/tap/fisk-ai

Other Operating System users can download the latest release from the releases page.

Basic agent

This example builds an AI agent that speaks in cowsay bubbles.

The steps make a quick CLI application using App Builder and then drive it in various ways using the LLM.

The example needs an Anthropic API key, the cowsay application (try brew install cowsay) and fisk-ai installed.

Creating a CLI tool

This example uses Choria Application Builder to create a basic CLI tool that can say and think. Any command line tool built with Fisk works.

First create an ABTaskFile:

name: cowsay
description: Tools for the Cowsay LLM Agent
author: fisk-ai@choria.io

commands:
  - name: say
    description: Say something using a talking cow, does not accept emoji
    type: exec
    arguments:
      - name: message
        description: The message to send to the terminal
        required: true
        validate: is_shellsafe(value)
    command: |
      {{ default .Config.Cowsay "cowsay" }} {{ .Arguments.message | escape }}

  - name: think
    description: Think something using a thinking cow, does not accept emoji
    type: exec
    arguments:
      - name: message
        description: The message to send to the terminal
        required: true
        validate: is_shellsafe(value)
    command: |
      cowthink {{ .Arguments.message | escape }}

Now install appbuilder:

$ brew tap choria-io/tap
$ brew install appbuilder

Then confirm the CLI tool works:

$ abt
usage: abt [<flags>] <command> [<args> ...]

Tools for the Cowsay LLM Agent

Help: https://choria-io.github.io/appbuilder

Commands:
  help [<command>...]
  say <message>
  think <message>
$ abt say 'Hello AI'
 __________
< Hello AI >
 ----------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Creating an LLM agent

Turning this CLI into an LLM agent needs an agent.yaml file.

# Command to introspect and expose as an agent
application_path: /opt/homebrew/bin/abt

harness:
  # Allow the LLM to prompt us for information if needed
  human_in_the_loop:
    enabled: true

llm:
  # Choose a Model and set safety budgets
  model: claude-haiku-4-5-20251001
  budget:
    max_tokens: 100000
    max_iterations: 50

# We want a cow joke machine!
system_prompt: |
  Tell jokes using Cows!

  You have tools that can render a cow saying < 120 character sentences, when asked use the tools to tell funny jokes.

  You tell cow jokes, no other kinds of jokes, strictly jokes about cows. If asked to tell non cow jokes, refuse and show no joke.

  Keep narration short, just stick to the jokes, don't say what you are doing or planning to do, just do it and don't repeat the joke

Run the agent after setting the API key:

$ export ANTHROPIC_API_KEY="....."
$ fisk-ai run --tool-output --no-tui 'tell me a joke '
-> say -- Why did th...space?
<-  
 ______________________________
< Why did the cow go to space? >
 ------------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

-> think -- To visit t...oooon!
<- 
 ______________________
( To visit the Moooon! )
 ----------------------
        o   ^__^
         o  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||


  There you go! A classic cow joke for you!

Run summary: model=claude-haiku-4-5-20251001 llm_calls=2 tool_calls=2 tokens=3536/113 latency=3.613s

The default is a running TUI. To make the output easy to show here, the run passes --no-tui and shows the tool call output with --tool-output.

Now ask about a cat joke:

$ fisk-ai run 'tell me a joke about a cat'

  I appreciate the request, but I only tell jokes about cows! I'm strictly a cow joke specialist.

  If you'd like to hear some funny cow jokes instead, I'd be happy to moo-ve right into those for you!

Run summary: model=claude-haiku-4-5-20251001 llm_calls=1 tool_calls=0 tokens=1632/54 latency=1.341s

Running the agent

The agent has three specific modes of execution:

  • A shell script style output, plain text to STDOUT with an exit at the end of the task
  • A TUI for interaction, optionally continuing to chat with the agent after the main task completes
  • Serving the agent over the network using an Agent-to-Agent protocol (planned)

TUI

The TUI mode is the default: a visual runner with hot-keys to show or hide thinking and tool output, and scrolling up and down the session history. A chat box can optionally be enabled to continue a session.

In the TUI press the ? key to get interactive help.

Chat after turn

In the TUI mode the --chat flag keeps a Chat bar usable once the prompt is processed, instead of exiting, for follow up questions related to the session.

Type a follow-up and press Enter to send it; Ctrl-D ends the session, Ctrl-C aborts it. Up/Down recall this session’s earlier follow-ups. Alt-Enter (Option-Enter) moves to the next line rather than send.

Shell mode

The TUI is turned off with --no-tui, and the system falls back to a simple terminal output format suitable for scripting.

The model’s prose is markdown: both the final answer and any mid-conversation updates. When stdout is a terminal it is rendered for readability with a style matched to the terminal background; when stdout is piped or redirected, the raw markdown is written so the result stays free of ANSI escape codes. Rendering can also be disabled with --no-color, or the standard NO_COLOR environment variable.

Output is separated by kind. Only the final answer goes to stdout; everything else goes to stderr: the commands being run, mid-conversation updates, a final run summary (LLM calls, tool calls, tokens, latency), and, when thinking is enabled, the model’s reasoning (each line prefixed with a thought bubble). This keeps stdout safe to pipe into other tools.

One-shot runs

The common use case gives a system_prompt that describes the goals and approach (think of it as a one-file SKILL) and a user prompt that provides the question to solve.

The LLM runs through the prompt and, once it reaches the end of its turn, finishes processing, and the session cannot continue later. This resembles a shell utility.

HTTP debugging

As a debug or learning aid, all the HTTP requests can be logged to http-debug.log using the --http-debug flag.

Model and run settings

The agent.yaml sets which model runs the agent, the budget that bounds a run, and whether the model exposes its reasoning. The Basic agent example above shows these together. The full set of configuration fields is in the configuration reference.

Model

llm.model selects the model and is required. It accepts any model identifier the Anthropic API accepts:

llm:
  model: claude-sonnet-5

Larger models reason better on complex, long-horizon tasks; smaller models like Haiku are faster and cheaper for narrow ones. When the agent exposes ten or more tools it relies on the model’s server-side tool search, which recent models support and older ones (Claude Opus 4.1 and earlier and local models) do not. The configuration reference lists the known models and their trade-offs.

Budget

llm.budget bounds a single run so the agent loop cannot spend without limit:

llm:
  budget:
    max_tokens: 200000
    max_iterations: 50
    call_timeout: 120s
SettingDescription
max_tokenscumulative token spend cap for the run, default 200000
max_iterationsmaximum agent loop iterations, default 50
call_timeoutper-call timeout as a duration string, default 120s

The run stops with a summary once a budget is reached, whether or not the task is complete.

Thinking

Extended thinking lets the model expose its reasoning before it answers. It is off by default:

llm:
  thinking:
    enabled: true

When enabled, the reasoning is surfaced separately from the answer: as thought-bubble lines on stderr in shell mode, and as folding thinking blocks in the TUI.

Note

Older models that predate adaptive thinking, such as Sonnet 4.5 and Haiku 4.5, reject the request. Leave thinking off for those.

Terminal UI

Two harness settings govern the full-screen UI for an agent, independent of the per-run --no-tui flag:

harness:
  no_tui: true
  no_bell: true
  • no_tui is a persistent off switch: the agent always uses the line-by-line output, even on an interactive terminal, and the command line cannot turn the UI back on. Use --no-tui instead for a one-off run.
  • no_bell silences the terminal bell. By default the full-screen UI rings the bell each time a run blocks on an approval gate or an ask_human_* prompt, so a waiting run is noticed even when unattended.

Both are negative switches and have no effect in the line UI.

Tool selection

Run the fisk-ai info command to verify what tools the agent has access to:

$ fisk-ai info
╭───────────────────┬────────┬───────────────────────────────────────────────────────┬──────╮
│ TOOL              │ SOURCE │ DESCRIPTION                                           │ TAGS │
├───────────────────┼────────┼───────────────────────────────────────────────────────┼──────┤
│ say               │ local  │ Say something using the configured command            │      │
│ think             │ local  │ Think something using a cow                           │      │
│ ask_human_confirm │ local  │ Ask the human operator a yes/no question at the te... │      │
│ ask_human_select  │ local  │ Ask the human operator to choose one option from a... │      │
│ ask_human_input   │ local  │ Ask the human operator to type a free-text value a... │      │
╰───────────────────┴────────┴───────────────────────────────────────────────────────┴──────╯

Prompt:

  Tell short jokes using Cows!
...

The output shows the say and think tools and some Human in the Loop tools.

There are a few ways to control what tools are visible.

Application tags

The application can declare that the LLM never gets the think tool:

  - name: think
    description: Think something using a cow
    type: exec
    tags: [ ai:deny ]
    # ...

Adding the ai:deny tag to a command means Fisk AI never exposes that tool to the LLM. fisk-ai info confirms the LLM only gets the say tool now.

Agent configuration

The agent.yaml can also include only certain tools:

include:
  tools:
    - ^say

Or exclude certain tools specifically:

exclude:
  tools:
    - ^think

This uses regular expressions over the tool name, and both can be used together. For example, include ^cow but exclude ^cow_think.

A tool’s name is its command path joined with underscores, so a nested command like cow think becomes the tool cow_think. Grouping commands and hidden commands are skipped and never become tools.

Tools can also be included or excluded by tag:

exclude:
  tags:
    - scope:system

This excludes any command that has the scope:system tag.

Global flags

A wrapped binary often has application-level global flags that apply to every subcommand. nats, for example, has --context to select a stored connection profile, alongside sensitive globals such as --user and --password. By default none of these are exposed to the model. global_flags is an allowlist of the globals you want the model to be able to set per command:

global_flags:
  - context

Each named global becomes an argument on every leaf command tool, so the model can run nats stream ls against a chosen context without you hard-wiring one. Names are the long flag name, with or without the leading dashes, and are validated against the binary’s real global flags at load; a name matching none is an error. Hidden and framework flags (like --help) cannot be exposed, and a global that clashes with a command’s own flag or argument is skipped for that command. A global the application marks required is always exposed, whether or not it is listed, since the command cannot run without it.

Run fisk-ai info to see which globals a binary exposes; it lists the application’s global flags and marks the ones you have allowlisted.

Session snapshots and resumption

Creating a snapshot

By default a run is ephemeral: its conversation lives only in memory and is lost when the process exits. --checkpoint instead journals the run to a session on disk so it can be suspended and resumed later, in a fresh process or on another machine. Sessions are the foundation for longer-running work where the agent may need to pause, for example while a slow external step completes.

Start a checkpointed run. fisk-ai prints the session id at startup; it is generated unless --name sets it:

$ fisk-ai run --checkpoint "report on the ORDERS stream"
$ fisk-ai run --checkpoint --name orders-report "report on the ORDERS stream"

Resume a session by id. No prompt is given, since the original prompt is restored from the session; passing one is an error:

$ fisk-ai run --resume orders-report

On resume fisk-ai replays the conversation so far to stderr, so the run continues in context rather than from a blank screen, then carries on from where it left off.

Chat sessions

--chat and --checkpoint combine into a durable, resumable conversation.

Each follow-up is journaled, so the whole conversation survives a suspend or a crash. Leaving the input bar with Ctrl-D suspends the session rather than ending it (the status bar reads ctrl-d suspend): it stays resumable, and fisk-ai prints how to resume it on exit. Ctrl-C aborts; the journal is kept, so an aborted chat is still resumable from its last completed turn.

Resuming a chat session reopens the input bar automatically; re-passing --chat is not needed (it is ignored on resume, since the session already knows what it is), and fisk-ai first replays the conversation into the viewport. Because the input bar needs a real terminal, a chat session can only be resumed in the full-screen UI, not with --no-tui or over a pipe. A checkpointed chat has no “completed” state; remove it with session rm once it is no longer needed.

Suspending

For a checkpointed run the first Ctrl-C, or a SIGTERM, requests a graceful suspend: the current step finishes, the session is checkpointed, and the process exits printing how to resume it. A second Ctrl-C aborts immediately. A run started without --checkpoint keeps the usual behavior, where Ctrl-C cancels it.

Durability

A session is journaled event by event as the run proceeds: each model turn and each tool result is recorded as it happens.

  • A clean suspend is exactly-once. Nothing runs after the last recorded event, so a resume never repeats a tool call or an LLM call.
  • A crash resumes from the last recorded event, so at most one tool call is repeated. A tool whose side effect completed but whose result was not yet recorded runs again on resume, since fisk-ai cannot make an external side effect idempotent. Already-recorded turns and results are never replayed.

Resume a session against the same agent configuration it started with. A session can be resumed from anywhere, including a machine that no longer has the original agent.yaml, so care is required: continuing a conversation against a different model, tool set, or system prompt can make the replayed transcript incoherent. fisk-ai guards this by fingerprinting the configuration (model, prompt, tool set, budget) at checkpoint time and refusing a resume when it no longer matches; the refusal names what changed. --force overrides the check, accepting that the restored conversation may not fit the current configuration. A session that already completed cannot be resumed.

Managing sessions

Sessions are stored under the XDG state directory, $XDG_STATE_HOME/fisk-ai/runs, defaulting to ~/.local/state/fisk-ai/runs; --state-dir overrides the location. A suspended or completed session is kept until it is removed.

fisk-ai session ls
fisk-ai session show <id>
fisk-ai session show <id> --transcript
fisk-ai session rm <id>

session ls lists each session with its status, model, and prompt. session show prints a session’s counters and status; --transcript shows the full conversation (prompt, thinking, narration, tool calls, and tool output). On an interactive terminal --transcript opens the full-screen viewer with thinking and tool output folded, which z and Z expand; --no-tui/NO_TUI prints it as line output instead. session rm deletes a session.

Human in the loop (HITL)

When enabled, fisk-ai gives the model built-in tools to ask the operator a question at the terminal and wait for the answer. They are off by default and only available when running the agent:

harness:
  human_in_the_loop:
    enabled: true

Three tools are offered:

  • ask_human_confirm - a yes/no question. Returns {"confirmed": true} or {"confirmed": false}
  • ask_human_select - choose one of a list of options the model provides. Returns {"selected": "<option>"}, or {"selected": null} if no choice was made
  • ask_human_input - a free-text value, optionally pre-filled with a default the operator can accept or edit. Returns {"value": "<text>"}, or {"value": null} if none was given

Optional communication from the agent

The model decides when to call the HITL tools, shaped through the prompt. They suit decisions the model should not make alone: confirming a destructive action, choosing between options that depend on operator intent, or supplying a value it cannot derive. The question is rendered on the terminal (stderr, so a piped final answer stays clean), and the model-supplied text is stripped of terminal control sequences first so it cannot spoof what is shown. Each tool denies by default: an interrupt, an end-of-input, or no terminal at all yields a negative answer (no confirmation, no selection, no value) rather than a guess. They require an interactive terminal: without one the call is declined with a reason rather than hanging on a prompt no one can answer, and they are never exposed over MCP, where there is no operator. Tool calls within a turn run one at a time, so a prompt has the terminal to itself.

Required tool use confirmations

Two mechanisms put a human in the loop:

  • human_in_the_loop (a configuration flag) lets the model ask its own question through a fisk-ai-provided ask_human_* tool, with no application command involved. The human answers a question the model chose to ask.
  • ai:confirm (a command tag) lets the application author gate an ordinary, non-interactive command so the operator must approve it before it runs. The human is a checkpoint on a command the model wanted to run anyway; nothing about the command itself changes.

Reach for human_in_the_loop when the model should decide when to check in; reach for ai:confirm when a normal command should run only with the operator’s say-so, typically something destructive or irreversible.

Command Tags

Fisk commands can carry tags, set in their fisk definition (or, for App Builder applications, in YAML). Tags can be referenced by the include/exclude rules to select commands by group, and a few tags are reserved and interpreted by fisk-ai itself to control how a command is exposed to the model:

TagDescription
ai:denyNever expose the command to the model; it is dropped before include/exclude and can never be added back.
ai:no_deferAlways send the command directly instead of deferring it behind the tool-search tool.
ai:confirmRequire the operator to approve the command at the terminal before it runs; an “allow for the session” answer is remembered for that command for the rest of the run.

ai:deny is the reliable way to keep a command the agent should never call out of reach, since it applies before any include/exclude rule. ai:no_defer keeps the handful of commands the model needs on most requests immediately available rather than discoverable only through tool search.

ai:confirm gates a command behind the operator’s explicit permission. When the model calls a command tagged ai:confirm, fisk-ai pauses before running it and prompts the operator at the terminal, showing the resolved command line with its arguments, and offers three choices: run it once, run it and stop asking for that command for the rest of the session, or decline. Declining returns an authoritative result to the model (the command is not run and the model is told the decision is final), so it stops rather than working around the refusal. An “allow for the session” answer is remembered by command, regardless of its arguments: once you bless stream rm, every later stream rm call runs without asking again, so reserve that choice for a command you trust the agent to repeat. It applies for the rest of that run only; nothing is persisted across runs. The prompt is rendered on stderr (so a piped final answer stays clean), the displayed command line is stripped of terminal control sequences so model-supplied argument values cannot spoof what you see, and it denies by default: an interrupt, an end-of-input, or no interactive terminal declines rather than runs. Unlike human_in_the_loop, the tag is always active: there is no configuration flag to enable it.

The same gate can be extended to other tags with the harness.confirm_tags configuration key: any tag listed there gates its commands exactly as ai:confirm does, which lets an operator require confirmation for a tag the application already uses (for example impact:rw) without editing the application. It is additive to the always-on ai:confirm tag and matching is exact rather than a regex. A confirm_tags entry that matches no loaded command is reported as a warning at startup, since a typo would otherwise leave a command ungated. The approval prompt names the tag that gated the command, so you can tell why you are being asked. Run fisk-ai info to see each command’s tags and which commands a run would gate. Like ai:confirm, a confirm_tags tag gates both the agent loop and MCP, where it is requested through elicitation.

Any other tags are free-form: they have no built-in meaning to fisk-ai but can be matched by the tags field of an include or exclude rule.

All of a command’s tags, reserved and free-form alike, are also included in the tool description fisk-ai sends the model, as a trailing Tags: ... line, in both the agent and over MCP. This lets your prompt reference them, for example “always use ask_human_confirm before running any command tagged impact:rw”. The human-facing fisk-ai info listing keeps the plain description.

Memory

Memory gives the model a small key/value store that persists across runs, so it can keep durable notes (a layout it worked out, a convention, the outcome of an investigation) and pick them up next time rather than rediscovering them. It is opt-in and agent-mode only; like the human-in-the-loop tools it is never exposed over MCP.

Warning

Memory files are shared state. Treat what a memory contains as data the model saved, not as trusted instructions.

harness:
  memory:
    enabled: true
    backend: file
    options:
      directory: memory

When enabled the model is offered four tools: memory_list (keys and their descriptions), memory_read (one memory by key), memory_write (save a memory with a key, a one-line description, and a body), and memory_delete. A key uses letters, digits and ., _, = or - (no slashes or spaces), which keeps it valid both as a filename and as a NATS KV key. memory_write creates by default and refuses to overwrite an existing key unless called with overwrite: true, so the model does not silently clobber a note; the create still fails cleanly if two writers race for the same new key.

At the start of a run the stored keys and descriptions are injected into the system prompt as an index so the model knows what it has saved; memory_list is the live view during the run. Turn the index off with no_index: true.

The file backend keeps each memory as a markdown file named for its key under the configured directory, which defaults to memory/<identity>. The identity is the agent’s name, set with the identity configuration field and defaulting to the application binary’s base name; the configuration reference covers it in detail. Point two agents at the same directory and they share a memory; leave the default and each agent keeps its own. Because the files are shared state, treat what a memory contains as data the model saved, not as trusted instructions.

We can use memory to ensure our agent never repeats jokes; change thesystem_prompt as follows:

harness:
  memory:
    enabled: true

system_prompt: |
  Tell short jokes using Cows!

  You have tools that can render a cow saying short sentences, when asked 
  use the tools to tell funny jokes.
  
  You tell cow jokes, no other kinds of jokes, strictly jokes about cows. 
  If asked to tell non cow jokes, refuse and show no joke.

  Do not use emoji, keep general narration short, just stick to the jokes

  Save the jokes you told to a single memory file with all the past jokes 
  and make sure you dont repeat jokes you previously told.

  Finish your turn by making a funny quip related to the joke or cows or similar

We will get a new joke every time - be ready to get some awful jokes after a while :)

Safety

When Fisk AI runs a command in a CLI tool it passes a slice of arguments to the exec system call. No shell is involved that can be escaped or influenced.

App Builder is often involved and calls shell scripts, so App Builder commands need to be written defensively.

  • Use type hints on arguments for ints, floats and so on
  • Use is_shellsafe(value) on string input arguments
  • Use escaping when passing arguments to commands, for example {{ .Arguments.message | escape }}
  • Tag commands with the various helper tags so the harness understands the intent
  • Mark every mandatory argument as required

Fisk AI has no tools that can interact with arbitrary files on the system. The only way it interacts with the system is through the supplied tools or the Memory feature.

Every command the agent runs gets the same protections:

  • Its output combines stdout and stderr, preserving order, and is capped at 64 KiB so a chatty command cannot flood the model’s context
  • The ANTHROPIC_API_KEY is stripped from its environment, so a tool can never read the agent’s own credentials
  • LLMFORMAT=1 is set, signalling fisk applications to render output suited to an LLM rather than a terminal

Local LLMs

Local LLM hosting tools like ollama, LM Studio and others support exposing an Anthropic-compatible API. Fisk AI can communicate with those tools.

There are some caveats. To support a large number of tools, Fisk AI uses the Tool Search Tool, which these local runners do not support. When targeting a locally hosted model, the total tool count may need to stay around 15.

I set these environment variables before invoking fisk-ai to access my local Anthropic API instead of reaching to the internet.

$ export ANTHROPIC_BASE_URL=http://localhost:1234
$ export ANTHROPIC_API_KEY=lmstudio

MCP Servers

Instead of running an agent loop, Fisk AI can serve a Fisk application’s commands over the Model Context Protocol so another client, such as Claude Desktop or Claude Code, calls them directly as tools.

The tool set, the input schemas, and the tag rules are the same ones the agent uses. Only the caller changes: where the agent drives the tools with an LLM against a prompt, an MCP server hands the same tools to whatever client connects and lets it decide when to call them.

Note

Serving over MCP is opt-in. The configuration must carry an expose.agent.mcp block, otherwise fisk-ai mcp refuses to start.

Starting a server

Serving over MCP needs less configuration than an agent: there is no agent loop, so system_prompt and llm.model are not used. A minimal config needs only the application to introspect, a tool selection, and the expose.agent.mcp block:

application_path: /usr/local/bin/nats
include:
  tools:
    - ^stream_
expose:
  agent:
    mcp: {}

Start the server with the mcp command:

$ fisk-ai mcp --config nats.yaml --port 8080

The transport is HTTP, the streamable MCP transport. The port is taken from --port (or FISK_AI_MCP_PORT); if unset, from expose.agent.mcp.port in the config; otherwise it defaults to 8080. All progress and logging go to stderr.

Use fisk-ai info to preview which tools a configuration exposes before starting the server:

$ fisk-ai info --config nats.yaml

Connecting a client

Wire an MCP client to the server by pointing it at the running URL. For Claude Code:

$ claude mcp add --transport http nats http://127.0.0.1:8080

A client that takes a JSON server map uses:

{
  "mcpServers": {
    "nats": {
      "type": "http",
      "url": "http://127.0.0.1:8080"
    }
  }
}

Configuration

MCP mode uses only the parts of the configuration that describe the application and the tool set. An identity becomes the MCP server name, defaulting to fisk-ai when unset; system_prompt, llm.model, and the agent-only harness settings are ignored.

FieldDescription
application_pathpath to the Fisk application binary to introspect and serve; optional, omit it to serve only allowlisted built-ins such as knowledge_search
expose.agent.mcpthe opt-in block that enables MCP serving; must be present
expose.agent.mcp.portdefault listen port when --port and FISK_AI_MCP_PORT are unset, default 8080
expose.agent.mcp.instructionsfree-text guidance sent to clients on connect
expose.agent.mcp.confirm_over_mcphow confirmation-gated commands behave when a client cannot be asked
include / excludeselect which commands become tools, matched on tool name (regex) or tag
expose.agent.toolsnarrow the exposed set further within the include/exclude selection
identitythe MCP server name; optional

Instructions

expose.agent.mcp.instructions sets a block of free text sent to clients when they connect. A client may pass it to the model as a hint about how to use the server, which suits orientation the individual tool descriptions are too terse to carry:

expose:
  agent:
    mcp:
      instructions: |
        These tools wrap the NATS CLI. Prefer stream_info before stream_edit,
        and treat all subjects as relative to the FOO account.

How tools are exposed

Each command becomes an MCP tool named by its command path, for example stream_info, with its input schema and a description built from the command’s help. Both the short help and any long help are surfaced to the client, so a command that carries detailed long help gives the model richer guidance than a one-line summary alone.

Each tool also carries MCP annotations:

  • a readable title, the space-separated command path, so stream rm rather than the underscore tool name
  • a read-only hint, derived from the command’s tags, when a command declares through a tagging convention that it only reads rather than mutates its environment, so a client can tell a safe query from a command that changes state; a command that declares no impact is left without the hint

The served tools are the agent’s include/exclude selection, narrowed further by expose.agent.tools when it is set. With neither, every command is served, subject to the tag rules below. Tool selection uses the same regular expressions over the tool name as the agent. A tool call runs the command and returns its result; a per-call timeout and a concurrency limit bound execution.

Command tags over MCP

The reserved command tags are honored over MCP, with two differences from the agent loop:

TagBehavior over MCP
ai:denynever exposed, the reliable way to keep a command off MCP entirely
ai:no_deferno effect, since MCP does not defer tools behind a tool-search tool
ai:confirmexposed and gated through elicitation rather than a local operator prompt

All of a command’s tags, reserved and free-form alike, are included in the tool description as a trailing Tags: ... line, the same as in the agent, so a client’s prompt can reference them.

Confirmation over MCP

Commands tagged ai:confirm, or a configured confirm_tags tag, require approval before they run. There is no local operator on the MCP path, so Fisk AI requests approval from the calling client through MCP elicitation: before running a gated command it asks the client to approve, showing the server name, the resolved command line, and the tag that gated it, and runs the command only on an explicit approval. A refusal, a dismissal, or any elicitation error denies the call and returns an authoritative result the model is told not to retry.

Not every client supports elicitation. expose.agent.mcp.confirm_over_mcp chooses what happens when the connected client cannot be asked:

ValueBehavior
autodefault; ask clients that support elicitation, run the command ungated for clients that do not
alwaysask clients that support elicitation, refuse the command for clients that cannot be asked
nevernever ask, run gated commands ungated regardless of client support, delegating approval to the client’s own UI
expose:
  agent:
    mcp:
      confirm_over_mcp: always
Warning

Elicitation is a request, not an enforcement boundary. A client is free to auto-approve, and under auto or never a client that cannot elicit runs gated commands ungated. For a command that must never be reachable over MCP, use ai:deny rather than relying on confirmation.

A client that already has its own approval UI may prompt twice under auto or always; set never when the client’s own gating is trusted and the second prompt is unwanted.

What is not served

The built-in operator tools are agent-mode only and are never exposed over MCP, since there is no local operator on the MCP path:

Safety

Every served command gets the same per-command protections as the agent: it runs as an argument vector rather than through a shell, its arguments are bound to the command’s schema, its ANTHROPIC_API_KEY is stripped, its output combines stdout and stderr and is capped at 64 KiB, and LLMFORMAT=1 is set.

The threat model is wider than an agent run and worth understanding:

  • Any client that can reach the server’s port can invoke every exposed tool with any schema-valid arguments. ai:deny and include/exclude are the gate on what is reachable, so scope the exposed set deliberately.
  • There is no agent loop, prompt, or token budget bounding aggregate use. A per-call timeout and a concurrency limit bound execution, but not the total number of calls, so do not expose the server on an untrusted network.
  • Command output is returned to the connected client rather than to Anthropic, so whoever connects sees whatever the commands print.
  • Confirm-tagged commands are gated by elicitation, a request the client fulfills rather than an access control the server enforces. Use ai:deny, not confirmation, for anything that must never be reachable over MCP.

Knowledge (RAG)

Knowledge gives an agent a search tool over a locally built index of its own markdown and text documents. The agent gains a single in-process knowledge_search tool that returns the most relevant sections of the indexed corpus, each with a citation, so it can ground its answers in project documentation rather than its training data.

In AI terms this is a RAG (retrieval-augmented generation) system contained entirely in a single binary and a single process. It is aimed at keeping source data local and handles markdown files. It runs with or without a local embedding model; without one it uses full-text search alone.

Everything ships in the one fisk-ai binary. The index is a single SQLite file built and queried in-process, with no CGo and no external database. The orchestrating LLM stays remote at Anthropic or a local compatible model; only storage and retrieval are local. A local embeddings server is the only optional external process, and only when semantic search is turned on.

Note

Knowledge is opt-in and off by default. Like the memory tools it is only wired into the agent loop, though knowledge_search can be exposed over MCP through an explicit allowlist.

Enabling knowledge

The minimal enable needs no model and no server. Turn the feature on and point it at the documents to index:

harness:
  knowledge:
    enabled: true
    paths:
      - docs/

Build the index and search it from the command line, then run the agent, which now has the knowledge_search tool:

$ fisk-ai knowledge index docs/    # build the index, incremental, no embeddings needed
$ fisk-ai knowledge search "backpressure"
$ fisk-ai run "how does backpressure work?"

The index is incremental. A second knowledge index re-reads only files whose content changed, detected by hash, and reconciles deletions when a full configured root is walked.

Two retrieval tiers

Knowledge has two retrieval tiers. The lexical tier is always on and is the default; the vector tier is a separate opt-in.

The two tiers match a query in different ways. The lexical tier matches on words: a query and a section rank together when they share the same terms. The vector tier matches on meaning: a query and a section rank together when an embedding model places them close in vector space, even when they share no words. Lexical search is exact and literal; vector search is fuzzy and semantic. Hybrid mode runs both and fuses the results, so a query gets lexical precision on the terms it names and vector recall on the ideas it only paraphrases.

Lexical, the default

The lexical tier is an FTS5/BM25 full-text index. It is always active when knowledge is enabled and needs no embedding model, no external service, and no per-query cost. This is the baseline the feature works on for everyone, and for a corpus of local technical documents it is often all that is needed.

Lexical search is strongest when the query uses the corpus’s own vocabulary: an exact identifier, a command name, an error string, or a term of art the documents themselves use. Its limit is the mirror image. A query worded differently from the documents can miss a section that explains the same idea in other words, since a section that never uses the searched term does not match however relevant it is.

Semantic, opt-in

Add an embeddings block to turn on the vector tier. Its presence is the switch: with no block, knowledge stays lexical-only. When it is set, each chunk is embedded through a local OpenAI-compatible embeddings server, and a query is answered by fusing the lexical and vector rankings with Reciprocal Rank Fusion behind the one search call.

The benefit is recall on meaning rather than wording. A natural-language question finds the right section even when it shares no keywords with it: asking “how do I stop the agent spending too much” can surface the section on budgets though that section never says “spending”. This suits an agent, which phrases a search in its own words rather than the documents’ exact terms. Fusing the two tiers keeps lexical’s precision on named terms while adding this semantic reach, so the hybrid result is usually better than either tier alone.

The cost is what the vector tier adds. It needs a local embedding model and server to run, a --reindex to embed the existing corpus, and one embedding call per query at search time. Retrieval stays local either way; the vector tier trades the extra moving part for better recall on paraphrased and conceptual queries.

harness:
  knowledge:
    enabled: true
    paths:
      - docs/
    embeddings:
      base_url: http://127.0.0.1:1234/v1
      model: text-embedding-embeddinggemma-300m

The embedding model is user-chosen, so nothing about it is assumed. fisk-ai knowledge doctor probes the configured server and reports the model, its vector dimension, and whether its output is normalized. After turning embeddings on, rebuild the index so the vectors are populated:

$ fisk-ai knowledge doctor
$ fisk-ai knowledge index --reindex
$ fisk-ai knowledge stats

Changing the model, its dimension, or a prefix changes the vector identity and forces a --reindex. The index refuses a mismatched model upfront, before embedding anything, rather than silently returning wrong rankings.

Tier line

Every surface, the CLI commands and the knowledge_search tool result, prints one canonical tier line so it is never ambiguous which tier answered a query:

tier: lexical (FTS5) - no embeddings configured
tier: hybrid (FTS5 + vectors, RRF) - model=<name> dim=<n>
tier: hybrid -> DEGRADED to lexical (embeddings unreachable: <reason>)

A configured embeddings server that is unreachable at query time degrades to lexical-only and says so, rather than failing the search. A configured embeddings server that is unreachable at index time fails loud, so an index the user asked to be semantic is never silently built lexical-only.

When to enable embeddings

Start lexical. It has nothing to run and no per-query cost, and it is often enough on its own. Add embeddings when the searches that matter are worded differently from the documents.

AspectLexical (default)Hybrid (with embeddings)
Matches onshared words, exact termsmeaning, plus shared words
Best foridentifiers, command names, error stringsnatural-language questions, paraphrased queries
Needsnothing beyond the binarya local embedding model and server
Per-query costnoneone embedding call
Index costtext index onlya --reindex to embed the corpus

The two are not exclusive: enabling embeddings keeps the lexical tier and fuses the two, so nothing is lost by turning it on beyond the extra model to run.

Configuration

The harness.knowledge block mirrors harness.memory. An absent block, or enabled: false, means off.

harness:
  knowledge:
    enabled: true
    paths:
      - docs/
    directory: ""
    top_k: 5
    max_injected_tokens: 6000
    embeddings:
      base_url: http://127.0.0.1:1234/v1
      model: text-embedding-embeddinggemma-300m
      api_key_env: RAG_EMBED_KEY
      timeout: 30s
      query_prefix: ""
      document_prefix: ""
FieldDescription
enabled (boolean)turns the feature on; absent or false means off
paths (array)default index roots used when knowledge index is run with no path argument
directory (string)store location, resolved relative to the working directory; default knowledge/<identity>
top_k (integer)default retrieval count, default 5, hard ceiling 20
max_injected_tokens (integer)cap on the total retrieved text fed to the model, default 6000
embeddingsoptional block; its presence turns on the vector tier

The directory follows the same rule as memory’s options.directory: resolved against the working directory when it is not absolute, and defaulting to knowledge/<identity>. The identity is the agent’s name, so two agents pointed at the same directory share an index and the default keeps each agent’s index its own.

Embeddings

The embeddings block is only read when the vector tier is on. It describes a local OpenAI-compatible endpoint that fisk-ai POSTs to at <base_url>/embeddings.

FieldDescription
base_url (string)OpenAI-compatible base URL; requests go to <base_url>/embeddings
model (string)the embedding model name to request
api_key_env (string)name of an environment variable holding the API key, never the secret itself; optional
timeout (duration)per-request timeout, default 30s
query_prefix (string)text prepended to a query before embedding; optional, default empty
document_prefix (string)text prepended to a chunk before embedding, supports {title}; optional, default empty

api_key_env names an environment variable rather than carrying the secret, so no secret lives in agent.yaml and none is logged. Prefixes default to empty because the model is user-chosen and a wrong prefix is worse than none; the models that need one document it. Run knowledge doctor to see whether a chosen model expects a prefix.

Note

A non-loopback base_url must use https. The embeddings endpoint is only ever contacted when the vector tier is on; the lexical path makes no network calls.

The knowledge_search tool

When knowledge is enabled the agent is offered one tool, knowledge_search, that takes a query and an optional top_k. It runs the lexical search, adds and fuses the vector search when the vector tier is on, and returns the ranked sections. The effective count is min(requested or configured top_k, 20), and the total returned text is capped at max_injected_tokens.

Each result carries a citation token of the form <relpath>#<ordinal>, the file path relative to the index root and the chunk’s position in that file, alongside the human-readable heading path of the section. The same token is printed by knowledge search and knowledge sources and accepted verbatim by knowledge show, so a result can be resolved back to its full text.

Results are returned to the model as untrusted reference data, framed as material to draw on rather than as instructions. When the store has no index 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.

Warning

Retrieved text is data the corpus contains, not trusted instructions. Treat a knowledge_search result the same way as a memory: content to reason over, not directives to follow.

CLI commands

The fisk-ai knowledge command builds and inspects the index. It is separate from the agent’s knowledge_search tool; the CLI never runs the agent. Every command reads --config (default agent.yaml) and prints the tier line.

CommandDescription
knowledge index [paths...]incremental build; requires a path argument or a configured knowledge.paths
knowledge search <query>retrieve from the CLI for tuning; prints citation, heading, and a snippet
knowledge show <relpath#ordinal>print one chunk verbatim, resolving a citation token
knowledge sourceslist indexed files with chunk counts and last-indexed time
knowledge doctorpreflight checks; probes the embeddings server only when it is configured
knowledge statstier banner, document and chunk counts, vector count, pinned model, store size
knowledge rm <source...>remove specific sources’ chunks by path
knowledge resetwipe the index; the bare form refuses and names --force

The command is also available as fisk-ai rag.

knowledge index is incremental and per-file: a file whose hash is unchanged is skipped, a changed file is re-chunked, and a walk of a full configured root reconciles deletions. --dry-run lists the files and an embedding-call estimate without embedding anything, and --reindex forces a full rebuild. Indexing walks markdown and text files only, by the .md, .markdown, .txt, and .text extensions, and always excludes the store directory itself and the memory/ directory.

knowledge doctor degrades for lexical-only users and never exits non-zero solely because embeddings are absent. It always checks that the store is present and writable, that FTS5 is compiled in, and that the configured paths resolve. Only when embeddings is configured does it probe the endpoint and check the stored model and dimension for a mismatch.

knowledge reset without --force refuses and names the document and chunk count it would delete; knowledge reset --force clears every row and leaves a clean empty index in place, ready for the next knowledge index.

Store location and layout

The index is project-local. It lives at knowledge/<identity> relative to the working directory, alongside the memory/<identity> store, which suits the one-project-per-directory workflow where an agent.yaml, a memory/ directory, and a knowledge/ directory sit side by side. The directory field overrides the location.

The store is a single SQLite file with its -wal and -shm sidecars. The agent opens it read-only while knowledge index is the single writer, so an index can be rebuilt while an agent runs without the agent seeing a half-written state. A cross-process lock stops two indexers from running at once.

Warning

The store uses SQLite WAL and its shared-memory sidecar, so every process must be on the same machine. Do not place the store on a network filesystem such as NFS or SMB.

Serving over MCP

knowledge_search is the one built-in tool that can also be served over MCP. It is read-only and needs no operator prompt, unlike the human-in-the-loop and memory tools, which stay agent-only. Exposure is off by default and enabled through an explicit allowlist:

expose:
  agent:
    mcp:
      port: 8080
      builtins:
        - knowledge_search

Only knowledge_search is accepted in builtins; listing any other built-in is a configuration error that names the exposable set. The MCP process opens the read-only store and, when embeddings are configured, embeds the query itself, so the embeddings server must be reachable from that process. Degrade-to-lexical and the stored model validation apply unchanged.

Warning

The MCP server binds every interface. Exposing knowledge_search lets any client that can reach the port read verbatim snippets of the indexed corpus. Bind localhost or front it with authentication if the corpus is sensitive.

Security

The index holds the verbatim text of every indexed document, unencrypted on disk. modernc.org/sqlite has no pure-Go at-rest encryption, so the posture matches the memory feature: the file and its sidecars are created 0600 inside a 0700 directory.

  • The 0600 permission protects the file from other users on the same host. It does not protect against disk theft, backups, or a stolen copy, so do not index secrets.
  • Retrieved chunks are framed as untrusted reference data and stripped of terminal control sequences before any TUI render, so indexed text cannot spoof the display or inject instructions.
  • Embeddings secrets are supplied by environment-variable name and never logged. A non-loopback embeddings base_url must use https, and the request timeout is enforced.
  • Over MCP the allowlist is the only gate, and only the read-only knowledge_search is ever served; no index or write path is reachable over MCP.

Reference

A Fisk AI agent is described by a single YAML configuration file. It names the application to drive, selects which of its commands become tools, and sets the model, the prompt, and how the harness behaves. The run, mcp, and a2a commands all read the same file; each uses the parts it needs and ignores the rest.

The --config flag selects the file, defaulting to agent.yaml in the working directory:

$ fisk-ai run --config nats.yaml "report on the ORDERS stream"

Each section below is a slice of that file: read it top to bottom and you have seen every setting Fisk AI understands. Fields that are required are called out as such; everything else has a working default and can be left out.

Note

Most agents need only a handful of these settings. The Agents guide walks through building one from scratch; this page is the exhaustive list to reach for when you want to know exactly what a field does.

A minimal file

The smallest useful agent names the application, sets a model, and gives a prompt:

# agent.yaml - drive the NATS CLI as an agent

application_path: /usr/local/bin/nats

llm:
  model: claude-sonnet-4-6
  budget:
    max_tokens: 200000
    max_iterations: 50
    call_timeout: 120s

system_prompt: |
  You manage NATS JetStream Streams using tools.

Everything after this point expands on those blocks and adds the optional ones.

A knowledge-only agent

application_path is optional. Leave it out to run an agent with no wrapped application, on the built-in tools alone. This is useful for a knowledge agent that answers from an indexed corpus over knowledge:

# agent.yaml - answer questions from a local knowledge base, no wrapped app

llm:
  model: claude-sonnet-4-6

system_prompt: |
  You answer questions using the knowledge_search tool over the indexed docs.

harness:
  knowledge:
    enabled: true

With no application_path, the identity defaults to fisk-ai; set an explicit identity to keep the knowledge/<identity> and memory/<identity> stores separate when you run more than one such agent in a directory.

Identity and application

# The name of the agent. Used in discovery and reused as a NATS queue
# group, so it must contain only letters, digits, "-" or "_". If you
# leave it out it defaults to the application binary's base name, or to
# "fisk-ai" when no application_path is set; set it explicitly when the
# derived name carries a dot, a space, or other characters, which are
# rejected, or to keep memory/knowledge stores separate between agents.
identity: nats

# Path to the Fisk application binary to introspect and run. OPTIONAL.
# When set, the binary is introspected once at startup to obtain its
# command tree and per-command JSON schemas. Leave it out to run an agent
# on the built-in tools (knowledge, memory, human_in_the_loop) and any
# remote tools alone, with no wrapped application. Required only for "a2a"
# mode, which serves the wrapped application's tools.
application_path: /usr/local/bin/nats

# The system prompt describing what the agent should do. REQUIRED for a
# "run", ignored by "mcp" and "a2a" mode. Think of it as a one-file SKILL:
# describe the goals and give broad guidance.
system_prompt: |
  You manage NATS JetStream Streams using tools.

identity is load-bearing beyond a label: it is the NATS subject key when the agent serves or is discovered over agent-to-agent, and the default memory directory is memory/<identity>. Keep it to the safe character set so those uses stay valid.

application_path is optional for run and mcp modes and required only for a2a, which serves the wrapped application’s tools and cannot serve the built-ins. When set, the target must be built with a current Fisk (v0.9.0 or newer) that supports --fisk-introspect and precomputed per-command schemas. When it is left out, Fisk AI skips introspection entirely and the agent runs on its built-in and remote tools alone; see a knowledge-only agent below.

Tool selection

include and exclude choose which of the application’s commands become tools. Each takes a list of regular expressions matched against the tool name, and a list of fisk tags:

# Keep only the commands whose tool name or tag matches. When "include" is
# present, a command must match it to be exposed.
include:
  # Regular expressions matched against the tool name: the command path
  # joined with underscores, so the "stream info" command is "stream_info".
  tools:
    - ^stream_
    - ^consumer_info$
  # Match commands by fisk tag. An empty string "" matches untagged
  # commands. The reserved ai:deny tag is always active and can never be
  # included back in.
  tags:
    - scope:read

# Remove matching commands. Applied as a filter: a command that matches
# "exclude" is dropped even if "include" allowed it.
exclude:
  tools:
    - ^stream_rm$
  tags:
    - scope:system

A tool’s name is its command path joined with underscores, so a nested command like stream info becomes stream_info. Grouping commands and hidden commands are skipped and never become tools. include and exclude can be used together: for example include ^stream_ but exclude ^stream_rm$. Commands tagged ai:deny are dropped before any of this runs and can never be added back.

Run fisk-ai info to preview the resulting tool set before a run.

Model and run budget

The llm block selects the model and bounds a single run. llm.model is the only required field in it:

llm:
  # The model identifier. REQUIRED. Accepts any value the Anthropic API
  # accepts; the well-known identifiers are listed under "Models" below.
  model: claude-sonnet-4-6

  # Bounds on a single run so the agent loop cannot spend without limit.
  # The run stops with a summary once any of these is reached, whether or
  # not the task is complete.
  budget:
    # Cumulative token spend cap for the run. Default 200000.
    max_tokens: 200000
    # Maximum agent loop iterations. Default 50.
    max_iterations: 50
    # Per-call timeout as a Go duration string, for example "60s" or
    # "2m". Default "120s".
    call_timeout: 120s

  # Controls whether the model exposes its reasoning. Off by default; when
  # off, no thinking is requested and the model uses its default behavior.
  thinking:
    # When true, asks the model to think and surfaces its reasoning
    # separately from the answer (thought-bubble lines on stderr in shell
    # mode, folding blocks in the TUI). Older models that predate adaptive
    # thinking (Sonnet 4.5, Haiku 4.5) reject it, so leave it off for those.
    enabled: false

  # When true, disables Anthropic prompt caching for the run. Left off,
  # Fisk AI caches the stable prefix of each request to lower cost and
  # latency on multi-turn runs.
  no_prompt_cache: false

Larger models reason better on complex, long-horizon tasks; smaller models like Haiku are faster and cheaper for narrow ones. When the agent exposes ten or more tools it relies on the model’s server-side tool search, which recent models support and older ones do not; see Models.

Harness

The harness block governs how the agent harness behaves during a run, as distinct from the model (llm) or the tool selection. Everything in it is optional and the whole block can be omitted to leave every setting at its default. These settings apply to the agent loop only; mcp and a2a mode ignore them.

harness:
  # Opt-in built-in tools that let the model ask the operator a question
  # at the terminal (agent mode only).
  human_in_the_loop:
    # When true, offers the model the ask_human_confirm, ask_human_select,
    # and ask_human_input tools. Off by default.
    enabled: true

  # Command tags that, in addition to the always-on ai:confirm tag, gate a
  # command behind operator approval before it runs. Matching is exact, not
  # a regex, and additive to ai:confirm. An entry that matches no loaded
  # command is reported as a warning at startup.
  confirm_tags:
    - impact:rw

  # A hard off switch for the full-screen terminal UI: the agent always
  # uses line-by-line output, even on an interactive terminal, and the
  # command line cannot turn the UI back on. Use the --no-tui flag for a
  # one-off run instead. Negative switch, no effect in the line UI.
  no_tui: false

  # The full-screen UI rings the terminal bell each time a run blocks
  # waiting on you (an approval gate or an ask_human_* prompt). On by
  # default; set true to silence it. Negative switch, no effect in the
  # line UI.
  no_bell: false

  # Opt-in built-in key/value store that survives across runs (agent mode
  # only).
  memory:
    # When true, offers the model the memory_list, memory_read,
    # memory_write, and memory_delete tools. Off by default.
    enabled: true
    # The store implementation. Defaults to "file", which keeps each
    # memory in a markdown file under a directory; it is the only backend
    # today.
    backend: file
    # By default the stored keys and descriptions are injected into the
    # system prompt at run start so the model knows what it has saved. Set
    # true to keep the store's contents out of the prompt. Negative switch.
    no_index: false
    # Backend-specific settings. For the "file" backend: "directory", the
    # path memory files live under, defaulting to "memory/<identity>".
    options:
      directory: memory

human_in_the_loop lets the model decide when to ask; the ai:confirm tag and confirm_tags gate a command the model wanted to run anyway. The two are compared in detail under Command tags and in the Agents guide.

Point two agents at the same memory directory and they share a memory; leave the default and each keeps its own. Treat what a memory contains as data the model saved, not as trusted instructions.

Command tags

Fisk commands can carry tags, set in their fisk definition or, for App Builder applications, in YAML. Any tag can be matched by include/exclude, and three are reserved and interpreted by Fisk AI itself:

TagMeaning
ai:denyNever expose the command; dropped before include/exclude and can never be added back. The reliable off switch.
ai:no_deferAlways send the command directly instead of deferring it behind the tool-search tool.
ai:confirmRequire the operator to approve the command at the terminal before it runs; always active, no config flag.

ai:confirm denies by default: an interrupt, an end-of-input, or no interactive terminal declines rather than runs. An “allow for the session” answer is remembered by command regardless of its arguments, for the rest of that run only. harness.confirm_tags extends the same gate to any other tag your application already uses. Over MCP these gates are requested through elicitation instead of a local operator prompt; over agent-to-agent, confirmation-gated commands are not served at all. The full behavior is documented under Command Tags in the Agents guide.

All of a command’s tags, reserved and free-form alike, are appended to the tool description Fisk AI sends the model as a trailing Tags: ... line, so a prompt can reference them.

Serving over MCP

To serve the same tools over the Model Context Protocol instead of running the agent loop, add an expose.agent.mcp block. It is opt-in: without this block, fisk-ai mcp refuses to start. MCP mode uses only the fields that describe the application and the tool set; system_prompt, llm.model, and the harness settings are ignored.

expose:
  agent:
    # The opt-in block that enables MCP serving. Must be present, even if
    # empty ({}), for "fisk-ai mcp" to start.
    mcp:
      # Default listen port, used when neither --port nor the
      # FISK_AI_MCP_PORT environment variable is set. Default 8080.
      port: 8080

      # Free-text guidance sent to clients when they connect. A client may
      # pass it to the model as a hint about how to use the server, a good
      # place for orientation the terse per-tool descriptions cannot carry.
      instructions: |
        These tools wrap the NATS CLI. Prefer stream_info before
        stream_edit, and treat all subjects as relative to the FOO account.

      # How confirmation-gated commands (ai:confirm or a confirm_tags tag)
      # behave when the connected client cannot be asked through
      # elicitation:
      #   auto   - default; ask clients that can elicit, run ungated for
      #            clients that cannot
      #   always - ask clients that can elicit, refuse for clients that
      #            cannot be asked
      #   never  - never ask, run gated commands ungated, delegating
      #            approval to the client's own UI
      confirm_over_mcp: auto

    # Optional: narrow the served set further, within the top-level
    # include/exclude selection. With neither, every selected command is
    # served (subject to the tag rules). Same regex-over-tool-name and tag
    # matching as the top-level filters.
    tools:
      include:
        tools:
          - ^stream_
      exclude:
        tools:
          - ^stream_rm$

The served tools are the agent’s top-level include/exclude selection, narrowed further by expose.agent.tools when set. identity, if set, becomes the MCP server name. Elicitation is a request the client fulfills, not an enforcement boundary; for a command that must never be reachable over MCP, use ai:deny rather than confirmation. The MCP Servers guide covers this mode end to end.

Agent-to-agent

Fisk AI agents can also serve tools to, and import tools from, one another over NATS with no LLM on the serving side. Both sides use a named NATS context, given as nats_context.

Note

A2A capabilities are under development, this is included here for completeness but subject to radical change

# Name of a NATS context (as managed by "nats context" and resolved by
# jsm.go) used to connect to NATS. REQUIRED when remote_tools is set or
# when running "fisk-ai a2a".
nats_context: ngs

expose:
  agent:
    # When true, "fisk-ai a2a" serves this agent's tools over NATS. Opt-in:
    # without it, "fisk-ai a2a" refuses to start. Like MCP mode, serving
    # needs only application_path, identity, nats_context, and the tool
    # selection. Confirmation-gated commands are never served, since there
    # is no operator to approve them.
    agent_to_agent: true

# Import tools from one or more remote fisk-ai agents over NATS and expose
# them to this agent alongside its local tools.
remote_tools:
  - # The remote agent's identity (also the NATS subject key). REQUIRED.
    name: nats
    # A prefix for the imported tool names. Applied only when a bare name
    # would clash with a local tool or another remote's tool. Defaults to
    # "name".
    alias: nats
    # Select which of the remote agent's tools to import, matched against
    # the tool name only. A "tags" filter cannot be honored, since
    # discovery does not carry tags, and an exclude-by-tag is rejected at
    # startup.
    include:
      tools:
        - ^stream_
    exclude:
      tools:
        - ^stream_rm$

Imported tools keep their own name where it is unambiguous, and take the <alias>_<name> form only when the bare name would collide. A run is strict: an unreachable or unimportable remote agent fails the run. fisk-ai info is lenient and reports each remote host’s reachability instead.

Models

Well-known Anthropic model identifiers are available as constants in the config package; any value the Anthropic API accepts may be used in llm.model, local LLMs will have their own convention. fisk-ai does not restrict what you enter here.

ConstantIdentifierNotes
ModelClaudeFable5claude-fable-5Most capable overall, for demanding reasoning and long-horizon agentic work; highest cost.
ModelClaudeOpus48claude-opus-4-8Most capable Opus tier; slowest and most expensive Opus.
ModelClaudeOpus47claude-opus-4-7Prior Opus release.
ModelClaudeOpus46claude-opus-4-6Earlier Opus release.
ModelClaudeOpus45claude-opus-4-5-20251101Earlier Opus release.
ModelClaudeSonnet5claude-sonnet-5Balanced capability, speed, and cost; good default.
ModelClaudeSonnet46claude-sonnet-4-6Prior Sonnet release.
ModelClaudeSonnet45claude-sonnet-4-5-20250929Earlier Sonnet release.
ModelClaudeHaiku45claude-haiku-4-5-20251001Fastest and cheapest; best for simpler tasks.

Every model in the table supports the server-side tool-search tool that deferred tool discovery relies on. Anthropic’s tool search is generally available on Claude Opus 4.5, Sonnet 4.5, Haiku 4.5, and later; Claude Opus 4.1 and earlier, and local models, do not support it. If you point llm.model at an older identifier or a local model while exposing ten or more tools, the model is left holding only the tool-search tool with no way to reach the deferred commands and the run stalls. With such a model, keep the exposed set below ten tools (around 15 for local runners) so every tool is sent directly.

Command-line flags and environment

Some behavior is set per run on the command line rather than in the file. The flags override the file where they overlap, except for the hard off switches (harness.no_tui), which the command line cannot re-enable.

FlagEnvironment variableDescription
--configPath to the configuration file. Default agent.yaml.
--api-keyANTHROPIC_API_KEYAnthropic API key. Required.
--base-urlANTHROPIC_BASE_URLAnthropic API base URL to use, for example a local Anthropic-compatible runner.
--http-debugHTTP_DEBUGDump Anthropic API request and response bodies to http-debug.log.
--no-colorNO_COLORDisable markdown rendering of the final answer, emitting raw text.
--no-tuiNO_TUIDisable the full-screen terminal UI for this run and use line-by-line output.
--chatKeep the full-screen UI open for interactive follow-ups after each turn.
--verboseVERBOSEShow more verbose output.
--traceWrite a JSON-lines trace of every LLM request and response to a file.
--checkpointJournal the run to a session that can be suspended and resumed.
--resumeResume a checkpointed session by id instead of starting a new run.
--state-dirOverride where sessions are stored, default $XDG_STATE_HOME/fisk-ai/runs.

The MCP server port also reads FISK_AI_MCP_PORT, which --port overrides and which in turn overrides expose.agent.mcp.port. Sessions, chat, and their durability semantics are covered in the Agents guide.

Safety

The configuration is the boundary on what the model can reach: application_path fixes the one binary it can drive (and with no application_path set the agent can drive no external binary at all), include/exclude and ai:deny fix which of its commands become tools, and nothing outside that set is callable. Commands run as an argument vector rather than through a shell, their arguments are bound to each command’s schema, the ANTHROPIC_API_KEY is stripped from their environment, output is capped at 64 KiB, and LLMFORMAT=1 is set. The Agents and MCP guides describe the full threat model for each mode.

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