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.

Where to go next

  • Basic agent: build a working agent from a CLI tool and run it
  • Model settings: which model runs the agent, what a run may spend, and whether it reasons
  • Tool selection: which commands become tools, and the tags fisk interprets
  • Session snapshots: journaling, continuing a conversation, and where sessions are stored
  • Session inspection: tool calls, cost and searching journaled sessions
  • Remote agents: point a terminal at an agent somebody else is running
  • MCP client: import tools from third-party MCP servers into a run
  • Human in the loop: let the model ask the operator a question and wait for the answer
  • Memory: a key/value store the model keeps across runs
  • Safety: how commands are run and what a tool can reach
  • Local LLMs: point fisk at an Anthropic-compatible endpoint you host

Subsections of Agents

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 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 thinking=0 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 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 thinking=0 latency=1.341s

Running the agent

The agent runs in one of these modes:

  • 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
  • Hosted behind a channel, taking work from a queue or serving its tools to other agents

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 bar opens once the prompt is processed, instead of exiting, for follow up questions related to the session. Every full-screen run works this way; --no-tui answers one prompt and exits, since it has no bar to open.

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. Ctrl-L empties the transcript on screen and leaves the conversation and any half-typed follow-up alone, where /clear does the opposite and drops the conversation while leaving the scrollback.

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, with --thinking, 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 settings

The agent.yaml sets which model runs the agent, the budget a run may spend, and whether the model exposes its reasoning. The Basic agent example 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. Set llm.no_tool_search to send every tool directly on an endpoint that does not implement tool search; when a large tool set cannot use it the run warns that all tools are being sent directly. The configuration reference lists the known models and their trade-offs.

Budget

llm.budget limits the agent loop so it cannot run without end:

llm:
  budget:
    max_tokens: 500000
    max_iterations: 50
    call_timeout: 120s
SettingDescription
max_tokenstokens a whole conversation may process, default 500000
max_iterationsagent loop iterations one turn may take, default 50
call_timeoutper-call timeout as a duration string, default 120s

The two caps have different scopes. max_iterations applies to a single turn, and every turn of a conversation gets the same allowance. max_tokens applies to the whole conversation, so every turn draws on one allowance. Start a new conversation to get a fresh one.

When a turn reaches the iteration cap, it stops, says so, and you can carry on with another prompt. When a conversation reaches its token cap, it is finished: the next prompt is refused before it runs. Start a new conversation, or raise llm.budget.max_tokens on the machine running the agent.

max_tokens counts tokens, not money. It adds up the uncached input, the output, and both prompt-cache tiers. A cache read counts the same as an uncached input token here even though it costs a fraction as much, so two conversations with the same token count can cost very different amounts. Set this value against your own usage rather than treating it as a spending limit.

Thinking

Extended thinking lets the model expose its reasoning before it answers. Some providers call this reasoning rather than thinking; it is the same setting.

llm:
  thinking:
    enabled: true

Reasoning is never displayed unless asked for. --thinking (or THINKING=1) shows it, on fisk run and on fisk session show --transcript.

thinking=N on the run summary and in the TUI status bar reports the tokens spent reasoning, shown whether or not reasoning is displayed. It is part of the output half of tokens=in/out, not extra.

Note

Older models that predate adaptive thinking, such as Sonnet 4.5 and Haiku 4.5, reject the parameter. Both explicit states send one, so for those models remove the thinking block rather than setting enabled: false. The same applies to an endpoint behind ANTHROPIC_BASE_URL whose proxy does not implement it.

Terminal UI

These 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

Fisk turns an application’s commands into tools for the model, alongside the built-in tools the configuration enables. fisk info shows the resulting set:

$ fisk 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 the Human in the Loop tools. When the configuration sets a model, fisk info also prints a Model section listing the resolved model and provider, whether thinking is enabled, and how tool search will behave.

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 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.

The filters cover every tool the agent gets, not only the application’s commands: a built-in is matched by its name, such as memory_write, and a tool imported from a remote agent or an MCP server by its final name, which is alias_tool when the importer prefixed it. Only a command carries tags, so an include that lists tags alone removes every built-in and import; a pattern beside the tags names one back.

Built-in tools

Fisk has a set of built-in tools that are enabled under harness.tools:

harness:
  tools:
    - name: base64_encode
    - name: read_file
      confirm: true
      options:
        root: /srv/corpus
        max_bytes: 1048576

Only listed tools are enabled. confirm: true requires user confirmation before each call. options holds the tool’s own settings; an unknown key is an error.

read_file

read_file reads one regular file under a configured root. Its argument is path. It returns:

FieldDescription
paththe path as requested
found (boolean)false when the file does not exist
size (integer)the file size in bytes
encodingutf-8 for text, base64 for content that is not valid UTF-8
contentthe file content in the form encoding names

The root option defaults to root_directory, or the working directory when that is unset; a relative root is resolved under root_directory. Paths are confined to the root: a leading slash is ignored, .. cannot leave it, and a symlink is followed only when its target is relative and stays inside. Only regular files are read.

max_bytes is the largest file the tool returns, 1 MiB when unset. A larger file is refused rather than truncated.

base64_encode

base64_encode encodes its text argument as standard base64 and returns {"encoded": "<base64>"}. It takes no options.

Serving

Both tools are served over MCP whenever harness.tools lists them and the include, exclude and expose.agent.tools filters leave them in; exclude: {tools: [^read_file$]} under expose.agent.tools serves base64_encode alone. Confirmation over MCP describes how confirm: true behaves with MCP clients. Neither tool is served over a2a.

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 the model may set per command:

global_flags:
  - context

Each named global becomes an argument on every leaf command tool. Names are the long flag name, with or without the leading dashes, and are validated against the binary’s global flags at load; a name matching none is an error. Hidden and framework flags such as --help cannot be exposed. 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.

fisk info lists the application’s global flags and marks the allowlisted ones.

Command tags

Fisk commands carry tags, set in their fisk definition or, for App Builder applications, in YAML. Any tag can be matched by include/exclude. The ai: prefix is reserved for the tags fisk interprets; the full vocabulary is under Command tags in the Reference guide. The tags that control how a command is exposed are:

TagDescription
ai:denynever expose the command; dropped before include/exclude and cannot 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 before it runs

The behavior tags ai:read_only, ai:destructive, ai:additive and ai:idempotent describe what a command does. They reach the model and, over MCP, the client; they gate nothing. An ai: tag fisk does not recognize is reported as a warning at startup and by fisk info.

When the model calls a command tagged ai:confirm, fisk prompts the operator at the terminal with the resolved command line and offers three choices: run it once, run it and stop asking for that command for the rest of the conversation, or decline. Declining tells the model the decision is final. An “allow for the conversation” answer is remembered by command name regardless of arguments: once stream rm is allowed, every later stream rm call runs without asking.

The conversation records each answer and honors it on resume; fisk session show lists what it holds. /clear and a --force resume across a changed configuration drop the answers. A resume with no terminal attached declines a gated command. The prompt is rendered on stderr, so a piped final answer stays clean, and the displayed command line is stripped of terminal control sequences. With no interactive terminal, or a prompt that cannot be shown, the command is declined. An interrupt or end-of-input at the prompt ends the run; the conversation stays continuable and asks again. The tag is always active; there is no configuration flag.

harness.confirm_tags extends the same gate to other tags, for example ai:destructive or an application’s own impact:rw. Matching is exact, not a regex. An entry that matches no loaded command is reported as a warning at startup. The approval prompt names the tag that gated the command. fisk info shows each command’s tags and which commands a run would gate. Over MCP a gated command is requested through elicitation.

Any other tag is free-form: it has no built-in meaning and can be matched by the tags field of an include or exclude rule.

All of a command’s tags are included in the tool description fisk sends the model, as a trailing Tags: ... line, in the agent and over MCP, so a prompt can reference them, for example “always use ask_human_confirm before running any command tagged impact:rw”. The fisk info listing shows the plain description.

Session snapshots

Every run is a conversation, and every conversation is journaled. You do not turn this on. Leave a run and continue it later, in a fresh process or on another machine, using the id it prints when it ends.

Continuing a conversation

Ask something. fisk prints the id of the conversation when the run ends:

$ fisk run "report on the ORDERS stream"

Continue it by that id. No prompt is given, since the conversation is restored from the journal; passing one is an error:

$ fisk run --resume t-3f2a9c...

fisk reads the conversation back before it goes on, so you continue in context rather than from a blank screen.

Against an agent somewhere else, see remote agents, the journal is on the worker rather than here, so --resume takes the conversation token instead of the id. fisk session show on the worker prints it.

Chat sessions

Every full-screen run is a durable, resumable conversation.

Each turn is journaled, so the whole conversation survives leaving, a stop or a crash. Press Ctrl-D to leave the input bar when you are finished for now; the status bar reads ctrl-d done. This does not end the conversation. fisk prints how to continue it as it exits. Ctrl-C asks the current turn to stop at its next safe point. The conversation is kept either way.

Resuming reads the conversation back into the viewport before the input bar opens, so you continue in context rather than from a blank screen. Because the bar needs a real terminal, a conversation can only be continued in the full-screen UI, not with --no-tui or over a pipe, where a run answers one prompt. A conversation has no “completed” state; remove it with session rm once it is no longer needed.

Stopping

The first Ctrl-C, or a SIGTERM, asks the run to stop where the conversation can be continued: the current step finishes, the turn is journaled, and fisk prints how to continue it. A second gives up on the run and leaves.

Both keep the conversation. The difference is that the first lets the turn reach a safe point, so the work it had already done is recorded rather than lost part way.

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 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 fingerprints the configuration when the conversation started and refuses to continue it when that no longer matches, naming what changed. --force overrides it, except for the provider: a session started against one llm.provider can never be resumed against another. A session that already completed cannot be resumed.

Managing sessions

A suspended or completed session is kept until it is removed. List, inspect, and remove sessions with the session subcommands:

fisk session ls
fisk session show <id>
fisk session show <id> --transcript
fisk 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.

session stats, session query and session search report a session’s cost, its tool calls, and the sessions matching a filter; Session inspection covers them.

Answering a deferred tool call

A tool can report that its answer arrives later rather than now. The run then suspends, releasing the process, and resumes once the answer exists. session show lists what such a session is waiting on under Waiting on, giving the tool_use id, the tool, and whatever the tool said it is waiting for.

The answer travels on a request carrying the conversation’s token, described in Answering after the run ended. The tool is never called again: it already started the work, which is why it deferred.

No tool that ships with fisk defers; the mechanism is for tools a Go program registers through agent.Options.CustomTools.

These commands read the file backend under --state-dir by default. To inspect sessions in a configured backend, a jetstream stream or a file directory named in the config, pass that config with --config:

fisk session ls --config agent.yaml
fisk session show <id> --config agent.yaml

Where a session is journaled is configurable through harness.sessions, which mirrors the shape of harness.memory. Two backends ship: file (the default) and jetstream Version0.0.3. The block is optional; leaving it out keeps the file backend under the XDG state directory.

fisk info shows a Sessions section with the resolved backend and, for the jetstream backend, the stream and NATS context, so you can confirm where sessions are stored without starting a run.

File backend

The file backend keeps each session as a JSON-lines journal under a directory. Sessions are stored under the XDG state directory, $XDG_STATE_HOME/fisk-ai/runs, defaulting to ~/.local/state/fisk-ai/runs. Set options.directory to move it off the default XDG path:

harness:
  sessions:
    backend: file
    options:
      directory: /var/lib/fisk-ai/runs

--state-dir overrides options.directory for a single run or session command, so the flag always wins over the configured path. It applies only to the file backend: combining it with a non-file backend is an error rather than a silently ignored flag.

JetStream backend

The jetstream backend keeps sessions as messages on a NATS JetStream stream instead of on disk, so a run suspended on one machine resumes on another over a broker. It uses the connection from the configured nats_context, the same one memory and remote tools use, and binds to a stream that must already exist: the agent never creates it, so you own the stream’s retention policy.

nats_context: production

harness:
  sessions:
    backend: jetstream
    options:
      stream: FISK_SESSIONS

Create the stream first, subscribed to one wildcard subject and keeping a single message per subject so each run record is write-once:

nats --context production stream add FISK_SESSIONS \
  --subjects 'fisk.sessions.>' --max-msgs-per-subject=1 \
  --discard=new --discard-per-subject

The stream keeps messages forever by default, which suits sessions; do not set a max age or they would silently expire. The subject prefix (fisk.sessions above) is yours to choose; the backend derives it from the stream’s single wildcard subject when it binds, so it is not set in the config. The backend fails at run start, rather than degrading silently, if the stream does not exist or its configuration does not match this shape.

The stream holds one namespace. fisk session ls lists every run on it, whichever agent wrote it, so keep separate environments in separate streams. A run does record the agent that started it, and the web channel filters its session list on that identity and on the id prefix it mints its own conversations under, so a browser reaches the conversations that agent minted for it rather than everything on the stream. The web protocol covers that listing. A run journaled before the identity was recorded carries none, and every listing shows it.

Session inspection

The session stats, session query and session search commands read journaled sessions without resuming them. stats reports what one session cost and where its time went, query prints the tool calls it made with their answers, and search finds sessions across the store by agent, model, caller, status, time and prompt.

The commands read the same store as session ls and session show: the file backend under --state-dir by default, or the backend a configuration names with --config. They read a journal without taking its lock, so they work against a session that is still running. See Session snapshots for where sessions are stored.

Note

A session journaled before records carried a time and the agent’s name reports no timing and no agent, and search --identity leaves it out. Older journals covers both.

$ fisk session stats 33Fp8QnWb3aZkR1vT0yXcLmE2sD
Session 33Fp8QnWb3aZkR1vT0yXcLmE2sD:

         Agent: nats-ops
         Model: claude-opus-4-8
        Status: completed
       Created: 2026-09-22 11:14:03
         Ended: 2026-09-22 11:14:24
    Iterations: 6 (at most 20 per turn)
    Wall clock: 21.81s (creation to the last journal record)
        Tokens: 41993 of 200000 budget (3790 in / 883 out / 33420 cache read / 3900 cache write)

  Model responses:

    โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
    โ”‚ Iteration โ”‚ In    โ”‚ Out โ”‚ Cache read โ”‚ Cache write โ”‚ Thinking โ”‚ Total โ”‚
    โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
    โ”‚ 0         โ”‚ 2,140 โ”‚ 96  โ”‚ 0          โ”‚ 3,900       โ”‚ 40       โ”‚ 6,136 โ”‚
    โ”‚ 1         โ”‚ 180   โ”‚ 142 โ”‚ 6,040      โ”‚ 0           โ”‚ 60       โ”‚ 6,362 โ”‚
    โ”‚ 2         โ”‚ 410   โ”‚ 88  โ”‚ 6,220      โ”‚ 0           โ”‚ 0        โ”‚ 6,718 โ”‚
    โ”‚ 3         โ”‚ 520   โ”‚ 74  โ”‚ 6,630      โ”‚ 0           โ”‚ 0        โ”‚ 7,224 โ”‚
    โ”‚ 4         โ”‚ 230   โ”‚ 71  โ”‚ 7,150      โ”‚ 0           โ”‚ 0        โ”‚ 7,451 โ”‚
    โ”‚ 5         โ”‚ 310   โ”‚ 412 โ”‚ 7,380      โ”‚ 0           โ”‚ 120      โ”‚ 8,102 โ”‚
    โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

  Tools:

    โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
    โ”‚ Tool             โ”‚ Kind        โ”‚ Calls โ”‚ Dispatched โ”‚ Not run โ”‚ Unanswered โ”‚ Errors โ”‚ Time  โ”‚
    โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
    โ”‚ consumer_info    โ”‚ application โ”‚ 2     โ”‚ 2          โ”‚ 0       โ”‚ 0          โ”‚ 1      โ”‚ 690ms โ”‚
    โ”‚ consumer_ls      โ”‚ application โ”‚ 1     โ”‚ 1          โ”‚ 0       โ”‚ 0          โ”‚ 0      โ”‚ 550ms โ”‚
    โ”‚ knowledge_search โ”‚ builtin     โ”‚ 1     โ”‚ 1          โ”‚ 0       โ”‚ 0          โ”‚ 0      โ”‚ 2.45s โ”‚
    โ”‚ stream_info      โ”‚ application โ”‚ 1     โ”‚ 1          โ”‚ 0       โ”‚ 0          โ”‚ 0      โ”‚ 400ms โ”‚
    โ”‚ stream_ls        โ”‚ application โ”‚ 1     โ”‚ 1          โ”‚ 0       โ”‚ 0          โ”‚ 0      โ”‚ 1.20s โ”‚
    โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

This session took six model responses and six tool calls over 22 seconds. One consumer_info call returned an error, and knowledge_search took the most time. session query shows that call and its answer:

$ fisk session query 33Fp8QnWb3aZkR1vT0yXcLmE2sD --errors
Session 33Fp8QnWb3aZkR1vT0yXcLmE2sD:

  Call 1:

           Tool: consumer_info
        Call ID: toolu_05
      Iteration: 3
         Status: error

    Input:

      {
        "stream": "ORDERS",
        "consumer": "SHIPING"
      }

    Answer:

      nats: error: consumer not found

Cost and timing

session stats <id> reports one session: its frame, the tokens of each model response, and a row per tool it called.

ItemDescription
AgentThe agent identity that started the session, left out when the journal records none
CallerWho a channel recorded as asking for the session, left out when there is none
ModelThe model the session ran against
Statusopen, or the reason the session ended
CreatedWhen the session was created
EndedWhen the session ended, left out for an open session
IterationsModel responses across the whole session, against the per-turn cap when one was set
Wall clockCreation to the last journal record
TokensTotal against the token budget, then the split by tier

The iteration cap applies to each turn on its own, so a conversation of several turns can show more responses than the cap. The wall clock runs to the last record journaled, so for a chat conversation continued over several days it includes the time between turns.

The token total counts uncached input, output, cache reads and cache writes, weighed the same, which is what a token budget counts. The Thinking column is the part of Out the model spent reasoning and is not added to the total a second time.

Tools table

ColumnDescription
ToolThe tool the model named
KindThe provider that answered: application, builtin, remote, custom, mcp or unknown
CallsCalls the model made to the tool
DispatchedAnswered calls that were handed to the tool’s provider
Not runAnswered calls that were not handed to the provider, such as a call a policy denied or the operator refused
UnansweredCalls with no result yet
ErrorsAnswers that came back as an error
TimeTotal time of the tool’s timed calls

A result journaled before the provider kind was recorded cannot say whether the call ran. It counts in neither Dispatched nor Not run, and its kind shows as unknown. One marked as a remote call in that older format shows as remote and counts as dispatched.

Reading the Time column

A call’s duration is the gap between the record that ended the call and the record journaled immediately before it. The agent dispatches tools one at a time, so no other tool ran in that gap.

The gap also holds anything the run did around the call without journaling it, such as policy hooks and waiting for an operator to answer an approval prompt. A tool behind a confirmation can therefore show the operator’s response time as well as its own.

A call that deferred its answer is timed to the deferral. The time until the answer arrived is not counted, since that depends on whoever supplies it. An approval given while the run was suspended is not counted either.

The Time column sums the durations of a tool’s calls. When only some of the calls could be timed, it says how many it covers, as in 1.10s (1 of 2 calls), and a dash means it covers none. Lines below the table say why:

LineMeaning
UntimedCalls that ended with a record carrying no time
UnansweredCalls with neither a result nor a deferral, such as one a suspended run left behind

JSON

--json renders the same report as one JSON document. The example is trimmed to one model response and one tool.

{
  "run_id": "33Fp8QnWb3aZkR1vT0yXcLmE2sD",
  "agent": "nats-ops",
  "model": "claude-opus-4-8",
  "created": "2026-09-22T09:14:03Z",
  "updated": "2026-09-22T09:14:24.81Z",
  "terminal": "completed",
  "ended": "2026-09-22T09:14:24.81Z",
  "iterations": 6,
  "max_iterations": 20,
  "max_tokens": 200000,
  "tokens": {
    "in_tokens": 3790,
    "out_tokens": 883,
    "cache_read_tokens": 33420,
    "cache_create_tokens": 3900,
    "thinking_tokens": 220
  },
  "responses": [
    {
      "iteration": 0,
      "time": "2026-09-22T09:14:05.1Z",
      "tokens": {
        "in_tokens": 2140,
        "out_tokens": 96,
        "cache_read_tokens": 0,
        "cache_create_tokens": 3900,
        "thinking_tokens": 40
      }
    }
  ],
  "tools": [
    {
      "tool": "consumer_info",
      "kinds": [
        "application"
      ],
      "calls": 2,
      "dispatched": 2,
      "undispatched": 0,
      "unanswered": 0,
      "errors": 1,
      "duration_ns": 690000000,
      "timed": 2,
      "untimed": 0
    }
  ]
}

terminal is absent for an open session. duration_ns is in nanoseconds, timed is how many calls it covers, and untimed counts the calls that ended with no time recorded. undispatched is the Not run column. agent, caller, updated, ended and each response’s time are left out when the journal does not record them.

Tool calls

session query <id> prints the tool calls of a session in the order they were made, each paired with the answer it got. With no flags it prints every call.

$ fisk session query 33Fp8QnWb3aZkR1vT0yXcLmE2sD --iteration 1 --calls-only
Session 33Fp8QnWb3aZkR1vT0yXcLmE2sD:

  Call 1:

           Tool: stream_info
        Call ID: toolu_02
      Iteration: 1

    Input:

      {
        "stream": "ORDERS"
      }

  Call 2:

           Tool: consumer_ls
        Call ID: toolu_03
      Iteration: 1

    Input:

      {
        "stream": "ORDERS"
      }

Each call shows the tool, the call id, the loop iteration that made it and, unless --calls-only is given, its status: answered, error or no answer yet. A call that deferred its answer also shows a Deferred line with what the tool said it is waiting on. Input and answers that are JSON are indented, and any other answer, such as a command’s output, prints as it is.

FlagDescription
--tool NAMESelects the calls to this tool, matched exactly; repeat to select several tools
--iteration NSelects the calls made at this loop iteration
--errorsSelects the calls whose answer is an error
--calls-onlyPrints the calls without their answers
--results-onlyPrints only the answers, leaving out calls nothing has answered
--input-match PATTERNSelects the calls whose input JSON matches this regular expression
--output-match PATTERNSelects the calls whose answer matches this regular expression
--jsonRenders the selected calls as one JSON document

Every filter given must match. --calls-only and --results-only cannot be combined. --input-match is applied to the input JSON as journaled, which a model usually sends compact: "consumer":"SHIP matches both consumer_info calls of this session, and a pattern holding the spaces of the indented form does not. A session with no matching calls prints No matching calls.

JSON

{
  "run_id": "33Fp8QnWb3aZkR1vT0yXcLmE2sD",
  "calls": [
    {
      "iteration": 3,
      "tool_use_id": "toolu_05",
      "tool": "consumer_info",
      "input": {
        "stream": "ORDERS",
        "consumer": "SHIPING"
      },
      "time": "2026-09-22T09:14:17.1Z",
      "answer": {
        "tool_use_id": "toolu_05",
        "result": {
          "tool_use_id": "toolu_05",
          "content": "nats: error: consumer not found",
          "is_error": true
        },
        "kind": "application",
        "dispatched": true
      },
      "answer_time": "2026-09-22T09:14:17.38Z"
    }
  ]
}

time is when the model response holding the call was journaled and answer_time when the answer was. answer is the result record as journaled, so content holds the whole answer. A call that deferred carries a deferred object, and a call with no answer has no answer. --calls-only leaves out answer and answer_time, and --results-only leaves out input. calls is an empty list when nothing matches.

Finding sessions

session search lists the sessions in the store that match every filter given, newest first. It prints the columns of session ls, and adds an Agent, Caller or Created column when a filter on that field is given.

$ fisk session search --status completed --since 2026-09-01
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ ID                          โ”‚ Model           โ”‚ Status    โ”‚ Created             โ”‚ Updated             โ”‚ Prompt                                              โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ 33Fp8QnWb3aZkR1vT0yXcLmE2sD โ”‚ claude-opus-4-8 โ”‚ completed โ”‚ 2026-09-22 11:14:03 โ”‚ 2026-09-22 11:14:24 โ”‚ report on the ORDERS stream and any consumers withโ€ฆ โ”‚
โ”‚ 2yKd9TqWm4RbX7cNz1LhVs0PfGe โ”‚ claude-opus-4-8 โ”‚ completed โ”‚ 2026-09-13 11:14:03 โ”‚ 2026-09-13 11:14:34 โ”‚ list the consumers on EVENTS                        โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
FlagDescription
--identity AGENTSelects the sessions of this agent; a session journaled before the agent was recorded carries none and is left out
--model MODELSelects the sessions run against this model
--caller CALLERSelects the sessions a channel recorded this caller for
--status STATUSSelects the sessions with this status: open, completed, suspended, error, budget or max_iterations
--since WHENSelects the sessions created at or after this time
--until WHENSelects the sessions created before this time
--match PATTERNSelects the sessions whose prompt matches this regular expression
--jsonRenders the matching sessions as one JSON document

--since and --until take a duration counted back from now (24h, 7d), a date taken as local midnight (2026-09-01), or an RFC3339 time. --status open selects every session with no ending: one still running, one with a turn in flight, and one that journaled something after it last ended, such as a chat conversation that was continued. --model, --caller and --identity compare exactly. A search that matches nothing prints No sessions matched.

JSON

{
  "runs": [
    {
      "run_id": "33Fq1cVx7MhN0bTe4KpWz9RgYuA",
      "agent": "nats-ops",
      "caller": "alice",
      "model": "claude-opus-4-8",
      "status": "open",
      "created": "2026-09-23T11:14:03Z",
      "updated": "2026-09-23T11:14:05.9Z",
      "prompt": "which streams have no consumers?"
    },
    {
      "run_id": "33Fp8QnWb3aZkR1vT0yXcLmE2sD",
      "agent": "nats-ops",
      "model": "claude-opus-4-8",
      "status": "completed",
      "created": "2026-09-22T09:14:03Z",
      "updated": "2026-09-22T09:14:24.81Z",
      "ended": "2026-09-22T09:14:24.81Z",
      "prompt": "report on the ORDERS stream and any consumers with pending messages"
    }
  ],
  "no_agent_excluded": 1
}

This is session search --identity nats-ops --json. status is open or the reason the session ended, and prompt holds the whole prompt, which the table trims. agent, caller, model and ended are left out when a session has none. no_agent_excluded counts the sessions --identity left out only because they carry no agent, and is 0 without --identity. runs is an empty list when nothing matches.

Older journals

A journal written before records carried a time and the agent’s name still reads, with less to report.

session stats shows no Agent line, reports the wall clock as not recorded in this journal, and shows - in the Time column with an Untimed line below the table. Tokens, calls and errors are still reported. In its JSON the calls count in untimed with duration_ns at 0, and the JSON of session query leaves out time and answer_time. The file backend reports such a session’s updated time as the journal file’s modification time.

search --identity leaves out every session that carries no agent and says how many it left out:

$ fisk session search --identity nats-ops
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ ID                          โ”‚ Agent    โ”‚ Model           โ”‚ Status    โ”‚ Updated             โ”‚ Prompt                                              โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ 33Fq1cVx7MhN0bTe4KpWz9RgYuA โ”‚ nats-ops โ”‚ claude-opus-4-8 โ”‚ open      โ”‚ 2026-09-23 13:14:05 โ”‚ which streams have no consumers?                    โ”‚
โ”‚ 33Fp8QnWb3aZkR1vT0yXcLmE2sD โ”‚ nats-ops โ”‚ claude-opus-4-8 โ”‚ completed โ”‚ 2026-09-22 11:14:24 โ”‚ report on the ORDERS stream and any consumers withโ€ฆ โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

1 more session matched every other filter but carries no agent, so --identity left it out. It was journaled before the agent was recorded.

The agent filter a channel applies to its own listing, such as the web channel’s, includes a session that carries no agent. --identity excludes it. To see such sessions from the command line, search without --identity or use session ls.

Remote agents

--nats-context points a terminal at an agent somebody else is running rather than starting one in this process:

$ fisk run --nats-context production "how many streams are there"

identity in the configuration names which agent to talk to, and you must set it. A default or a name derived from the application binary is shared by every agent built the same way, so the run could reach any of them.

The worker calls the model, runs the tools and writes the journal. Flags that describe that work are refused rather than ignored: --api-key, --base-url, --trace, --http-debug, --verbose, --state-dir and --no-telemetry. Setting one of these through an environment variable is ignored without an error, since you did not type it.

MCP client

mcp_clients imports the tools of third-party MCP servers into an agent run, alongside the wrapped application’s commands, the built-ins and any remote tools. Each entry names one server and selects a transport by which of command and url it sets: command starts the server as a child process and speaks stdio to it, url reaches an already-running server over streamable HTTP. Setting both is an error, and so is setting neither. Stdio and streamable HTTP are the only transports, and an endpoint that speaks the older HTTP+SSE transport is not supported.

mcp_clients:
  - name: filesystem
    alias: fs
    command: npx
    args:
      - -y
      - "@modelcontextprotocol/server-filesystem"
      - /srv/data
    env:
      FS_STATE_DIR: ${HOME}/.cache/fs-mcp
    timeout: 30s
    include:
      tools:
        - ^read_
    exclude:
      tools:
        - ^read_media_

  - name: docs
    url: https://mcp.example.net/mcp
    headers:
      Authorization: Bearer ${DOCS_TOKEN}
    timeout: 15s

Every field is described in the configuration reference.

Two entries sharing a name is an error when the file is parsed, and so is two entries whose effective alias is the same, since that alias prefixes every tool they both expose.

Naming

Every imported tool is named <alias>_<tool>, always, where the alias defaults to the server name. remote_tools prefixes only on a clash. MCP servers use short generic tool names such as search and read, where a clash is the common case, and a name derived only from its own server does not move when another server’s tool list does.

A collision against a local tool, a remote tool or another server’s is still possible. At the start of a run it fails the run, naming the tools that collided. fisk info reports it and carries on. A tool arriving while a run is under way whose name is taken is left out and reported, and the run continues.

Variable and file references

env, headers and url values hold any number of ${VAR} references, each replaced by the value of that environment variable, so a credential lives in the variable rather than in the file. A value mixes literal text with references freely, as in Bearer ${DOCS_TOKEN} and ${HOME}/cache. A $NAME without braces is literal text and references nothing. command and args are literal throughout.

A ${file:PATH} reference is replaced by the content of the file at PATH, which is how a Docker Compose secret arrives: Compose mounts it at /run/secrets/<name> and cannot expose it as an environment variable. Trailing whitespace is dropped, so the newline an editor or Compose leaves at the end of the file is not sent as part of the token, and an empty file is an error. A relative PATH resolves under root_directory. The path runs from file: to the first }, so a path holding a } cannot be written. Both forms work anywhere, including together in one value:

headers:
  Authorization: Bearer ${file:/run/secrets/docs_token}
env:
  API_TOKEN: ${file:/run/secrets/api_token}
url: https://mcp.example.net/mcp/?apiKey=${file:/run/secrets/key}

References resolve when a session connects, not when the configuration is parsed. Parsing checks their syntax and reads no variable and no credential file, so a host holding none of the credentials still runs fisk info and fisk mcp against the configuration. A variable that is not set, or a file that cannot be read, fails the connect, naming the variable or the path and the server.

Some services authenticate in the endpoint rather than in a header, by query parameter as in https://mcp.example.net/mcp/?apiKey=${DOCS_TOKEN}, or by a path segment as in https://mcp.zapier.com/api/mcp/s/${ZAPIER_KEY}/mcp. url takes references for that reason.

What is printed

Whatever a url’s references resolved to is replaced by REDACTED wherever it appears in an error or warning about that server, the endpoint an SDK or HTTP error quotes included, as long as the resolved value is at least eight characters. A shorter one is never searched for, since replacing a string that short would blank the digits and words it matches all through an unrelated message. Every endpoint printed anywhere is also redacted on its structure: the userinfo before the host, the value of every query parameter, and the fragment. The scheme, host, port, path and parameter names stay, so an operator recognizes the entry from their own file, and a reference is shown as written so it names the variable rather than its value.

Warning

A credential written into a URL path segment as a literal is printed in full, because nothing can tell a path segment holding a token from one naming a route. Put a path credential in a variable and reference it. A token under eight characters is printed either way, since the value redaction skips a string that short and the structural redaction leaves the path alone.

A stdio entry prints its command line, and each argument goes through the same URL redaction, so an npx -y mcp-remote https://host/sse?key=... bridge does not print its key.

Timeouts

timeout covers everything that happens for one server before the run starts, and it is applied twice: once around starting or reaching the server and the initialize handshake, and again around listing its tools. An entry that is slow at both steps takes up to twice the configured value, 60s at the default, before the run gives up on it. Unset it defaults to 30s.

Servers are connected one after another and listed one after another, so an entry that answers slowly or not at all delays every entry behind it by up to its own timeout. The timeout keeps that delay finite rather than preventing it: three entries that answer the handshake and then never return a tool list hold the run for 90 seconds at the default before it is refused.

A call to an imported tool is limited by harness.tool_timeout, like every other tool.

Trust posture

An imported MCP tool is never confirm-gated. ai:confirm and harness.confirm_tags reach the wrapped application’s commands, and the server applies no gate on its side either, so a call the model makes to an imported tool runs unapproved at both ends. include and exclude are therefore the only control an operator has over what a third party’s server can do in a run.

An imported MCP tool is never served on. Neither fisk mcp nor the a2a tool endpoint advertises one to its own clients, whatever expose.agent.tools selects: serving it would re-advertise a third party’s tool under this agent’s identity, and a client cannot tell which of the tools it is offered came from where.

A stdio server is a program of someone else’s choosing running as the operator’s user. It gets this process’s environment with the credential variables removed, the same scrub a command tool’s subprocess gets, and the entry’s env applied on top.

Failures and a moving tool set

A server that cannot be started, reached or listed fails the run, since the prompt may depend on tools that are not there. A tool the run cannot use, one with no description or a schema whose root is not an object, is skipped with a reason and the run continues on the rest.

A server can tell a live session that its tool list changed. The run re-lists that server, applies the entry’s filters and names the survivors, and the model sees the new set on its next call. A tool batch already dispatched runs against the set it was dispatched with, and no other server’s tools move.

Set watch_tools: true on the entry to hear that. It is off by default, and with it unset the tool list read when the session connects stands for the life of that session: a server that adds or removes a tool mid-run is not heard, and under fisk serve, where the sessions outlive the runs, the list read at startup stands until the session is replaced.

The default is off because subscribing opens a long-lived stream that the MCP SDK reconnects when it ends, and a server that answers the stateless server/discover handshake but refuses to resume that stream loses its whole session rather than only the subscription. A run against such a server then starts with none of its tools and fisk info reports it unavailable. Turn the key on for a server known to hold the stream open.

Seeing what a server offers

fisk info connects every configured server and prints an MCP clients section: where each is reached and over which transport, how long it took to answer, how many tools it advertised, how many the filters kept, the name each was imported under, and any tool left out with the reason. Discovery there is best-effort, so a server that is down is reported rather than failing the command. Its imported tools appear in the tool table with the alias in the Source column.

fisk serve connects the configured servers once at startup and shares those sessions across every run it hosts, so a long-lived worker is not starting and stopping a stdio child around each job. A server that cannot be reached stops the worker from starting, and the startup banner names the servers every hosted run imports from. Those runs share the server’s working directory, its authentication and its rate limits.

Human in the loop

When enabled, fisk 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

Enabling it offers these tools:

  • 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: with no terminal attached the call returns a negative answer (no confirmation, no selection, no value) and 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.

If you interrupt a question, or close the input, fisk does not treat that as a reply. The run stops there and the conversation is kept, and when you continue it the same question is asked again. No answer is recorded, so a run you interrupt never carries a decision you did not make.

Required tool use confirmations

These mechanisms put a human in the loop:

  • human_in_the_loop (a configuration flag) lets the model ask its own question through a fisk-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 covers the tag in full.

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 is shared state. Treat what a memory contains as data the model saved, not as trusted instructions.

Enable it under harness.memory. The backend field selects where memories are kept; it defaults to file, so the minimal configuration is just:

harness:
  memory:
    enabled: true

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.

read_only: true serves memory_list and memory_read and withholds the other two, for a run that should use what earlier runs saved without changing it. The store itself is unaffected, so anything else writing to it still does.

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.

A memory body is capped at 64 KB and a store holds at most 1024 entries. Both limits are shared by every backend, and a write that would exceed them fails cleanly. The on-disk format is shared too, so a value written by one backend migrates to another unchanged.

fisk info shows a Memory section with the resolved backend and, for the jetstream backend, the bucket, NATS context and key prefix, so you can confirm where memory is stored without starting a run.

Two backends ship today: file (the default) and jetstream Version0.0.3.

File backend

The file backend keeps each memory as a markdown file named for its key under the configured directory, which defaults to memory/<identity>.

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

A relative directory, including that default, resolves under the store base when a deployment sets one and against the working directory otherwise; an absolute directory is used as-is. 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.

JetStream backend

The jetstream backend keeps memories in a NATS JetStream KV bucket instead of on disk, so a fleet of agents can share durable memory over a broker. It uses the connection from the configured nats_context, the same one remote tools use, and binds to a bucket that must already exist: the agent never creates it, so you own the bucket’s durability policy.

nats_context: production

harness:
  memory:
    enabled: true
    backend: jetstream
    options:
      bucket: agent-memory

Create the bucket first, without a TTL so memories do not silently expire and with a max value size that fits a full entry (the 64 KB body cap plus the small frontmatter header stored with it), up to 1024 entries:

nats --context production kv add agent-memory --history=1 --max-value-size=69600

The backend fails at run start, rather than degrading silently, if the bucket does not exist, has a TTL set, or caps values below that full-entry size.

By default each agent’s keys are namespaced under a prefix equal to its identity (stored as <identity>.<key>), mirroring the file backend’s per-identity directory so two agents pointed at one bucket do not collide. Set options.prefix to a shared value for agents that deliberately share memory, or to "" for a flat, unprefixed keyspace:

harness:
  memory:
    enabled: true
    backend: jetstream
    options:
      bucket: agent-memory
      prefix: fleet-shared   # agents with the same prefix share memory; "" is flat

Read-before-update

The jetstream backend adds a safety guard the file backend cannot: an overwrite must follow a read of the current value, and is refused if the memory was not read or has changed since it was read. The model then reads the current value and retries. This is the same read-before-edit discipline that keeps an editor from clobbering a file it has not seen.

The read counts for the whole conversation rather than one turn. A memory read on Monday and edited on Friday in the same conversation is overwritten without a fresh read, as long as nothing else wrote to it in between; if something did, the write is refused and the model reads again before retrying. One conversation’s reads never authorize another’s overwrite.

The check uses the KV entry’s revision, which makes it an atomic compare-and-swap: when two agents share a bucket and both try to update the same memory, the second write is rejected rather than silently overwriting the first. The file backend’s last-writer-wins overwrite would quietly drop that change, so a shared or concurrent deployment wants this backend.

The guard is on by default. Set no_require_read_before_update: true to allow blind overwrites, matching the file backend’s behavior:

harness:
  memory:
    enabled: true
    backend: jetstream
    options:
      bucket: agent-memory
      no_require_read_before_update: true

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.

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 to access my local Anthropic API instead of reaching to the internet.

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

The base_url is validated only as a well-formed http or https URL naming a host, with no embedded userinfo credentials. Plain http is accepted for any host, since a local runner, a host gateway and a service on a private network all serve over it.