Design

The rest of the documentation describes what Fisk AI does. This section describes how it is built and why the design turned out the way it did.

The material here targets contributors, reviewers, and anyone auditing the harness before trusting it with a production tool. It names real packages, files, and symbols, and it states the invariants the safety story depends on.

  • Roadmap: Long term development goals
  • Code map: A guided read of the codebase: architecture, subsystems and the flows that connect them

Subsections of Design

Roadmap

This is a rough development roadmap, this isn’t set in stone - as we explore this area of development we might find additional features to add or directions to take.

This would give you guidance on our goals though.

Expand the RAG system with a GraphRAG layer

While the RAG system we have is sufficient for the kinds of use cases we target today - project documentation etc - it just is not good enough for all kinds of data we might encounter.

Ideally we use a LLM to extract taxonomies out of source material and then create a Graph from that. RAG to find the starting point, Graph queries to get the relationships. This allows us to answer questions like List all episodes of the TV Series Criminal Minds that had a female perpetrator.

See GraphRAG.

Integration with Job Systems

We need to integrate with job systems like Choria Async Jobs to facilitate external work arriving into our sphere.

Imagine a Webhook listener that receives a webhook and opens a Job. At this point the webhook is handled, the Agent will pick it up, do the work and update the record with the outcome

Finish the A2A system

Today we have tool calling using request-reply but no LLM prompting ability. We have the data types but nothing is wired up to them to facilitate true A2A

Choria Transport

Once the A2A system is mature expose it on the Choria transport with strong Identity, Authentication, Authorization and Auditing.

Move API out of internal package

A large goal of this project is to create libraries that can be used to build all sorts of AI Agent within the Choria ecosystem.

At the moment the libraries, while being developed and refactored, are all in internal. The aim is to make these public.

This would coincide with a end to end POC against Kestra doing complex problem solving using workflows as tools. This system would be running in a Orchestrator that creates new agents in containers.

Code map

Fisk AI turns a fisk command-line application into an LLM agent by introspecting its command tree and exposing the allowed commands as tools. This section is a reading guide to how that is implemented, for contributors, reviewers, and anyone auditing the harness before trusting it with a production tool.

Snapshot

Generated 2026-08-25 against tag v0.0.5. Commits after this one may make parts of this map stale.

The mental model

There is one core and several faces. A fisk application is introspected once into a set of tools; a YAML file narrows that set and decides what needs approval; and then the same selection is driven by a model in an agent loop, served to an MCP client, or served to other agents and to people. Three durable stores sit underneath, and none of them is part of the conversation the model sees until the harness decides to put it there.

fisk CLI appintrospected onceagent.yamlselects and gatestool set and loopone flat namespacegated, bounded, journaledrunterminal, human gatemcphttp clientsa2aother agentsservejobs and slackmemorymodel-written notesknowledgeoperator documentssessionsappend-only journalthe same selection drives every face; only some of them can reach a person
One core, four faces, three stores. A terminal run reaches its own agent over the same protocol it uses to reach somebody else's.

What the design optimizes for

Nothing is silently weakened. A configured confirm tag that matches no tool is warned about, because leaving it unreported would give a false sense of safety. A tool-name collision aborts the run rather than shadowing, because shadowing a gated command would strip its gate. A tag-based exclude on a remote host is rejected outright, because discovery carries no tags and the filter could never be honored.

Failures land at startup. An unknown backend, a typo in an options block, a bucket with a time-to-live, an unreachable peer, a stale knowledge manifest: all of them stop the process before the model is contacted, and the error names the key to change.

Untrusted text stays data. Model-written memories and retrieved documents are fenced, labeled as data rather than instruction, sanitized at write time, and sanitized again at render time.

How to read this section

Start with Architecture for the layering and the patterns that repeat, then Configuration, since every entry point begins by parsing a file. From there The agent loop and Tools and introspection are the core, and the rest can be read in any order.

Every page names the files and symbols it describes, states the invariants the safety story depends on, and says where something is declared but not yet wired.

Explore

  • Architecture: How the packages layer and which patterns repeat across them
  • Configuration: Parse agent.yaml once, default it, and validate it for the command that asked
  • The agent loop: Call the model, run the tools it asks for, journal each step, under a budget
  • Tools and introspection: Turn a fisk command tree, peer agents and MCP servers into one flat tool set
  • Model providers: Speak one neutral conversation model and translate it at a single boundary
  • Memory: Store model-written notes under validated keys and scoped overwrites
  • Knowledge: Index operator documents into one SQLite file and answer with citations
  • Durable state: Journal every step, fold it back into a resumable run, and keep the task record apart
  • Serving: Host an agent behind channels and speak one validated protocol to peers
  • Telemetry: Report a run as OpenTelemetry spans and metrics without letting content leak
  • The terminal: Render one conversation the same way live, replayed and piped
  • Reference: The command surface, the source map and the vocabulary

Subsections of Code map

Architecture

A package’s imports place it in one of four layers. The root holds commands and presentation, the middle holds agent and serve, and the leaves import nothing else from this repository.

package maincommand registration, flag parsing, terminal presentationagent, serverun the loop, host it behind channelstoolkit llm memory rag runstate a2a tasksa registry and several backends eachconfig telemetry util connsimport nothing else from this tree
An arrow is an import. The bottom band is the rule that keeps the middle band free of cycles.

The two hard leaves

config imports the standard library, a duration parser and a YAML library. internal/telemetry imports the standard library and OpenTelemetry.

Because config cannot see the rest of the tree, two lists are hand-maintained duplicates: the OTLP credential variable names, mirrored in telemetry, and the built-in tool names that may be exposed over MCP, mirrored on each tool’s own spec. Both are pinned by a test assertion so they cannot drift.

Because telemetry cannot see the rest of the tree, its constructors take primitives rather than domain types, and its error classes are unforgeable values rather than a classifier over somebody else’s sentinels. The HTTP middleware’s type is written out longhand rather than named, and the llm package declares both halves as type aliases, so the value satisfies the interface without either package importing the other.

Patterns that repeat

A registry with backends. Memory, sessions, tasks, model providers and a2a transports all follow the same shape: a factory registered from init under a name, a RequiresNats style option so the host resolves a backend’s needs without naming any backend, and a Register that panics on an empty name, a nil factory or a duplicate. Each registry has one or two implementations today, and adding a third needs no change in the host.

Failures land at startup. An unknown backend, a typo in an options block, a bucket with a time-to-live, an unreachable peer, a stale knowledge manifest: each stops the process before the model is contacted, and the error names the key to change. One decoder handles every backend’s options block, so no backend can relax the rule.

Nothing is silently weakened. A configured confirm tag matching no tool is warned about, because leaving it unreported would give a false sense of safety. A tool-name collision aborts the run rather than shadowing, because shadowing a gated command would strip its gate. A tag-based exclude on a remote host is rejected outright, because discovery carries no tags and the filter could never be honored.

Untrusted text stays data. Model-written memories and retrieved documents are fenced, labeled as data rather than instruction, sanitized at write time and sanitized again at render time.

Closed vocabularies are structs, not strings. The telemetry error class, the degrade reason and the MCP transport are each a struct wrapping a string, because a string type is convertible from any string and passing an error’s own text would compile.

State is derived, never cached beside its source. Counters, resume position and the committed conversation are all recomputed from the journal, so they cannot drift from what happened.

Resources are borrowed. A run uses every injectable store, connection and session as given, and never closes one. A host builds them once and shares them across runs; a CLI run falls back to building its own.

Where a decision is enforced

ConcernEnforced by
Which tools existThe flat namespace built at run start; collisions abort
Which tools the model may seeConfig include and exclude, after ai:deny is stripped unconditionally
Which tools need a humanThe confirm gate, on the union of the original and rewritten call
Which tools reach a peerThe exposure methods on the interface, plus a per-surface allowlist
Whether a conversation may continueThe run fingerprint, split into hard, blocking, tools and budget classes
What may leave the process on a spanClosed vocabularies and constructors that own their own attribute sets

The library standard

The packages under internal/ are being prepared to leave it, so others can build agents on them. agent, llm, telemetry, toolkit, memory, rag, runstate, util, conns, serve and agenttest are held to a public standard: names, signatures and doc comments are contracts.

Logic an embedder would have to reimplement does not belong in package main; the root holds command registration, flag parsing, presentation and wiring. A library supplies the value and the caller decides what to do with it, so where something is the CLI’s business, the library returns it or takes it as a parameter rather than deciding.

a2a, mcpserver, serve/asyncjobs and tasks are not there yet and their current APIs are not contracts. remotetools and tui are not libraries at all: one is the agent’s own run-path helper, the other is terminal presentation that happens not to live in the root.

agenttest is the embedder-facing test surface, and several of its fakes double as an audit: a compile-time assertion fails if an injectable interface stops being implementable from outside its own package using only exported identifiers.

Concurrency

One run is one goroutine. Every event, hook and prompt call happens on it, so a per-run sink holds state without locking. MCP advisories arrive on another goroutine and land in a mutex-guarded queue the loop drains where it takes tools for a call. The tool set is an atomic pointer to a whole immutable set.

A host runs many such goroutines with per-channel slot pools. They share the knowledge store, the MCP session set, the model provider, and stores that resolve per-run state per call rather than at construction. Each of those is read-only or safe for concurrent use.

Next

Start with Configuration, since every entry point begins by parsing a file, then The agent loop and Tools and introspection.

Configuration

Every command starts by reading agent.yaml. One file drives run, serve, mcp, info, knowledge, discover and session, so a bad entry fails the same way whichever command reads it.

Where it lives

config is a single file, config/config.go, holding the Config struct and every nested block, the parser, the defaults, the validator and about seventy accessors. It imports the standard library, fisk for duration parsing, and a YAML library. Nothing else from the tree.

Three stages, in order

ParseConfigForMode is the single funnel.

  1. Unmarshal strictly DisallowUnknownField makes a typo a hard error. UseJSONUnmarshaler fills every json.RawMessage block with canonical JSON, so a per-backend options block decodes identically whether the file was written as YAML or as JSON.
  2. Prepare Derive the identity, normalize enumerated strings, parse every duration into a matching ...Parsed field, and apply defaults. This step mutates in place.
  3. Validate for a mode The checks that always run, then the ones the calling command needs.
agent.yamlbytes on diskunmarshalunknown key failspreparedefaults, durationsValidateForModeper commandModeMCPno model, no promptModeAgentidentity, prompt, modelModeServechecked per endpoint
One parser, three validation modes. The mode is chosen by the command, not written in the file.

What each mode requires

ModeUsed byRequires
ModeMCPmcp, info, knowledge, discover, session, and run against a remote workerThe always-on checks and nothing more
ModeAgentrun hosting its own agentllm.model; identity and system prompt unless the run is MCP-only; a NATS context alongside remote_tools or jobs
ModeServeservePer endpoint. Jobs, Slack and a2a prompts each need a named identity, a prompt and a model; a2a.serve_tools needs an application path and a named identity

The always-on checks cover what any command pays for getting wrong: identity charset, global_flags without an application_path, remote tool hosts, and every mcp_clients entry.

ModeMCP is the most lenient on purpose. fisk info parses in it so it can report on a configuration it could not run.

Zero is not one answer

A duration of 0s means something different in each block.

Key0s means
harness.tool_timeoutUnbounded. The operator asked for no limit
mcp_clients[].timeoutRejected. An unlimited startup holds the whole run against a server that never answers
expose.agent.a2a.request_timeoutRejected. The transport reads a non-positive value as its own shorter default, so 0s would shorten the wait rather than remove it
expose.agent.slack.answer_graceRejected. It would defer every question the instant it was asked
llm.budget.call_timeoutRejected. It reaches the provider as a deadline already in the past, and every call fails with a context error that names nothing

telemetry.sample_ratio is a *float64 because an explicit 0 would otherwise arrive as the Go zero value, be defaulted back to 1.0, and send every trace to a paid backend. llm.thinking is a pointer for three states: absent says nothing to the provider, {enabled: true} asks for thinking, {enabled: false} asks for it off.

Switches that default on are spelled negatively for the same reason: no_tui, no_bell, no_prompt_cache, no_tool_search, no_metrics, no_index, no_progress. An absent bool unmarshals to false, so a positive metrics: true could not be told apart from an unset one.

Where a mistake lands

StageExamples
ParseAn unknown key, a malformed or out-of-range duration, a negative budget, an illegal identity or alias, a duplicate MCP server name, an mcp_clients entry with neither or both transports, a ${VAR} syntax error, a builtins entry that is not exposable
Command startupfisk mcp with no expose.agent.mcp block, fisk serve with no endpoint enabled, a knowledge subcommand with knowledge disabled, telemetry endpoint resolution, ${VAR} resolution at connect, a missing NATS stream or bucket
RunA provider name that no linked backend answers to, a reasoning effort the model rejects on the first call, embeddings settings validated when the knowledge store opens

A confirm_tags entry that matches no loaded tool is the one case that produces a warning rather than a failure, because the tool set is only known after introspection.

Load-bearing decision

The package never reads the environment. ExpandEnvReferences takes a lookup function instead of calling os.LookupEnv, because the commands that parse a configuration are not the commands that connect. ${VAR} syntax is checked at parse time and resolved at connect time, so fisk info can describe a file whose secrets are not present.

Load-bearing decision

Identity is the name other agents send traffic to. When it is derived from the application basename rather than written by an operator, identityDerived records that and IdentityIsNamed() reports it. Every serving path requires a named identity, because a fleet of unrelated agents built from one shared executable would otherwise register under a single name and answer each other’s traffic. For Slack, identity is also the first field hashed into a thread’s journal.

Credentials

Secrets stay out of the file and out of tool subprocesses.

${VAR} references
Recognized in mcp_clients env, headers and url. A value in command or args is taken literally.
CredentialEnvNames()
Strips OTLP credential variables from every tool subprocess whether or not this agent enables telemetry. These are ambient operator variables, a tool never needs them, and gating on config would mean --no-telemetry puts the token back into every tool subprocess.
RedactURL
Applied to anything that prints an MCP endpoint. It leaves the path intact, and some hosted MCP providers put the credential in the path.

Slack credentials are absent from the file entirely and come from SLACK_APP_TOKEN and SLACK_BOT_TOKEN.

Two blocks to read carefully

expose.agent.tools narrows only MCP and a2a.serve_tools, the two endpoints that hand a caller this agent’s tools. It does not narrow jobs, slack or a2a.prompts, which hand the agent a whole unit of work and run the full loop over the top-level tool selection.

llm.budget.max_tokens counts tokens processed. It weights a cache read the same as an uncached input token although the two are priced differently, and its 500000 default was chosen to stay out of the way of ordinary use. It does not cap spend.

Not yet wired

remote_agents is declared on Config and read by nothing. Working remote-agent functionality is remote_tools. A2ATransport() returns the constant nats, with the config key deferred until a second transport exists.

Next

Configuration decides what the run may do. Continue to The agent loop for what it then does, or Tools and introspection for how the selection becomes a tool set.

The agent loop

One iteration calls the model once, runs whatever tools it asked for, and feeds the results back.

Where it lives

internal/agent splits in two. agent.go is setup and teardown: load tools, resolve the provider, build stores, resolve the session, install hooks, and own the panic barrier. runner.go is the loop itself. Supporting files: toolset.go, hooks.go, events.go, approvals.go, pii.go, mcplive.go.

One iteration

  1. Poll for suspend Only at the loop boundary, and before the iteration index is consumed, so a suspend does not burn one.
  2. Take the tool set once A snapshot serves the model call and its whole tool batch. A tool removed mid-batch cannot strand a call the model already made.
  3. Call the model Under the per-call timeout the provider owns.
  4. Journal the assistant turn before running any tool A crash mid-batch resumes without paying for the model call a second time.
  5. Execute each tool call Validate arguments, apply the confirm gate, trace, dispatch, journal the result.
  6. Feed the results back All results become one user message and the loop iterates. If anything deferred, nothing is appended and the run suspends.
suspend pollloop boundarytool snapshotone set per callmodel callunder call timeoutjournal turnbefore any tooltool calls?yesexecute batchgate, run, journalnoanswerterminal turnresults as one user message
The dashed edge is where the tool results re-enter the conversation. Nothing crosses it while a call is unanswered.

What one tool call passes through

executeTool has eight exits and one deferred span covering all of them. In order:

  1. Registry lookup An unknown name is counted under its own kind and answered with an error result the model can adapt to, and the run warns. The call is never dispatched.
  2. Describe and check gating on the original call Done before any hook, so PreToolUse sees what the model actually asked for.
  3. PreToolUse May deny, or rewrite the tool and its arguments. A rewrite targeting an unregistered tool or carrying invalid JSON aborts the run rather than dispatching a malformed call.
  4. Argument validation Runs before the gate, so an operator is never asked to approve a structurally invalid call. A fisk command drops a missing required flag silently, so without this the failure would only surface as the command's own exit.
  5. Confirm gate Fires if either the original or the effective tool is gated.
  6. Trace and dependencies The tool receives a prompter or a work directory only if it said it needed one.
  7. Takeover check The last check before an irreversible effect. A run taken over on a shared store stops here rather than at its next append.
  8. Execute, then PostToolUse A timeout message is substituted before the hook runs, so hook, journal, event sink and model all see the same output.

A non-zero command exit is deliberately not an error outcome. It is an answer the model should reason about, and counting it would make the error rate meaningless.

Load-bearing decision

The gate fires on the union of the original and effective calls, so a PreToolUse hook cannot strip a gate by redirecting a gated call to an ungated tool.

Budgets and timeouts

LimitScopeZero means
llm.budget.max_output_tokensOne model reply. Defaults to 8192, raised to 16384 when thinking is onThe built-in default
llm.budget.max_tokensCumulative across the conversation: input, output, cache reads and cache writesUnbounded
llm.budget.max_iterationsAn absolute position, grown by the configured amount on each accepted follow-upRefused
llm.budget.call_timeoutOne provider call, enforced inside the providerRefused
harness.tool_timeoutOne tool call, as a context deadlineNo limit

The cumulative check runs before a tool batch and at the head of a follow-up turn, but deliberately after a completed answer is returned, because those tokens are already spent.

The tool timeout has two exemptions: an operator who asked for none, and a tool marked operator-paced, where the deadline would cancel the operator’s own question rather than a runaway command. A command tool is really killed with its process group; an in-process tool stops only when its handler observes the context.

Approval, deferral and suspend

human_in_the_loop adds the ask_human_* tools to the run. The confirm gate is independent of it: it stands in front of a tagged command and is default-deny, so with no prompter it refuses before asking anything.

The gate checks for a prompter, then for an already-expired context, and only then consults standing and one-shot grants. A grant restored from a journal therefore cannot run a gated command with nobody present.

A grant is honored from the moment it is given but staged, and written only after the triggering call is answered or deferred. A crash in between loses the grant, and the resume asks again for a command it is about to re-run. There is no standing denial, so a run that ended before the operator answered cannot persist a decision they never made.

A deferral is a tool saying it will answer later. The call is journaled as deferred, grants are flushed, and the run ends suspended. A deferred call is never dispatched again; its turn finishes only when an answer is supplied through the checkpoint.

Load-bearing decision

A turn is never committed while a tool_use has no result. If anything in a batch defers, nothing is appended to the conversation and the run suspends with the batch intact.

Hooks

In loop order. All run on the single run goroutine.

HookFiresCan
RunStartOnce, before a session is created or openedAbort
UserPromptSubmitOn each prompt entering the conversationDeny, rewrite
PreModelCallBefore each model call, above the providerAbort
PostModelCallAfter each reply, including a truncated oneAbort, but not durably: the turn is already journaled
PreToolUseBefore validation, gate, trace and executionDeny, rewrite the tool and its arguments
PostToolUseAfter execution, before the trace and journalReplace the output
TurnEndAt an interactive continuation boundaryAbort
RunEndAt teardown, after stores close, including on a crashNothing

PreToolUse is the only reliable place to block a tool.

PII scanning

The guard wraps UserPromptSubmit and PostToolUse, and the run installs it itself so every path into the loop is covered. It composes with the caller’s hooks rather than replacing them: the caller’s hook runs first and the scan reads whatever it left behind, including its rewrite.

Under redact a hit rewrites the text. Under reject a prompt is denied and a tool output is replaced with a fixed withholding message. A scan that fails is treated as a hit, and the cause reaches the operator through an advisory and the log rather than the model. The operator warning is raised once per run, because a chat redacting on forty tool calls would bury its own answer.

A mode other than off whose scanner will not build fails the run, because carrying on would send unscanned text to the model with no sign that scanning had stopped.

Setup and teardown

Tool assembly runs in a fixed order into one flat namespace, and every collision aborts the run rather than shadowing, because shadowing a confirm-gated command would strip its gate.

The panic barrier is registered after the telemetry spans so it unwinds first. It captures the stack before running any caller code, fires RunEnd exactly once, delivers the stack to the event sink inside its own recover, and substitutes a PanicError for the returned error. The stack stays off that error because the error may cross to a remote peer. It covers this goroutine only, not fatal runtime errors.

MCP advisories arrive on another goroutine and land in a mutex-guarded queue the loop drains where it takes tools for a model call, so an advisory arrives with the call that carries the set it is about. The tool set itself is an atomic pointer to a whole immutable set, so a reader never sees one change under it.

Next

Continue to Tools and introspection for how the set the loop dispatches against is built, or Durable state for what the journal holds and how a resume reads it.

Tools and introspection

A fisk application is introspected once into a set of tools. Peer agents, MCP servers, harness built-ins and caller-supplied Go functions land in the same namespace, and the model addresses every one of them by a single flat name.

Where it lives

internal/toolkit holds the vocabulary every kind shares: the Tool interface, tag behavior, the confirm predicate, the prompter and deferral. internal/toolkit/fisk turns a CLI command tree into tools, internal/toolkit/functool backs everything else, and internal/toolkit/builtin holds the harness’s own. internal/mcpclient and internal/remotetools import from elsewhere.

One namespace, five sources

fisk appcommand treebuilt-insmemory, knowledgea2a peersprefix on clashMCP serversalways prefixedcustom toolsregistered lastflat namespacecollision aborts the runfirst claim keeps itmodelone name per toolai:deny is stripped before any include or exclude runs
Sources are assembled in a fixed order. A name already taken aborts the run rather than shadowing what holds it.

The contract

Kind-specific policy is never folded into Tool; consumers reach for narrow capability interfaces instead.

type Tool interface {
	Name() string
	Description() string
	ModelDescription() string
	InputSchema() map[string]any
	Definition(deferLoading bool) llm.ToolDef
	Execute(ctx context.Context, input json.RawMessage, deps ExecDeps) (*Outcome, error)
	MCPExposable() bool
	A2AExposable() bool
}

Exposure is the deliberate exception to that rule. It sits in the interface so the compiler forces every new kind to answer it, since a new kind would otherwise reach MCP or a2a with no exposure decision recorded.

A tool that does not implement BehaviorDescriber is safe, because consumers fall back to conservative defaults. A tool that cannot answer Confirmable is refused by both serving surfaces, because “needs no gate” and “cannot report a gate” must not look the same.

fisk.FiskCommandTool runs a subprocess of the wrapped application; functool.Tool backs everything else, discriminated by whether its spec names a remote agent or an MCP server.

Kind
The accounting axis: application, builtin, remote, custom, mcp, unknown. A log line and a metric label carry the kind.
Presentation
The visibility axis: command, remote, self-rendered, traced. A built-in presents as self-rendered or traced while being one kind; an MCP tool presents as remote while being accounted as MCP.
Outcome
Output plus an optional exec record. The presence of that record is the whole discriminator: a command's output is wrapped in a CommandResult envelope, an in-process tool's is passed through as the JSON the caller asked for.

Introspecting a fisk application

Running <binary> --fisk-introspect returns the command model. Hidden commands and their subtrees are skipped, grouping nodes are not tools, and only leaves become tools, named by joining the command path with underscores. Every leaf must arrive with a precomputed schema or the whole load fails.

The introspection subprocess gets thirty seconds when the caller supplied no deadline, and its output is capped at 16 MiB. Over the limit is a rejection rather than a truncation, because the document has to decode whole.

Model arguments reach the binary only as argv, never through a shell. Exposed global flags are merged into each command’s schema by cloning rather than mutating, since the model schema is reused on every request, and they are placed after the command path and before the -- separator so they always read as flags.

Command output is captured through a single writer on both streams, so stdout and stderr keep their interleaving, and held to a head-and-tail ring within 64 KiB so a runaway command cannot grow the process.

The reserved tag vocabulary

ai:deny, ai:no_defer and ai:confirm change what the harness does. The behavior tags are advice.

TagEffect
ai:denyStripped before include and exclude run, and it can never be added back on any surface
ai:no_deferAlways sent to the model directly, never hidden behind tool search
ai:confirmAlways gated, matched unconditionally, so leaving it out of confirm_tags cannot weaken it
ai:read_only, ai:destructive, ai:additive, ai:idempotentCarried to clients as advice and nothing more

Operators gate further tools by listing them under confirm_tags, and any tag works, not only ai: ones. The trigger reported in the prompt prefers ai:confirm when present, and otherwise names the first of the tool’s own tags found in the operator’s list, in the tool’s tag order, so the message is deterministic.

Load-bearing decision

The behavior vocabulary describes and does not enforce. No behavior tag gates a call, because the tool supplies its own tags and could drop ai:destructive to escape the gate. The gate is ai:confirm plus the operator’s confirm_tags; the reliable off switch is ai:deny.

Conflicting tags resolve conservatively and never fail a run: the tags come from a binary the operator often cannot edit, so one mistagged command must not stop everything. read_only combined with a write tag loses read_only, and destructive beats additive. Resolution happens after all tags are collected, so it does not depend on order. An unrecognized ai: tag is a warning, since a private one is legitimate.

Collisions

The run builds a taken map in a fixed order: application tools, then human-in-the-loop, memory, knowledge, a2a peers, MCP servers, and custom tools last.

SourceOn a clash
Built-insAbort the run, naming the tool to exclude or rename
a2a peersPrefix with the host alias, but only when a local tool holds the name or more than one host exposes it, and then symmetrically for all of them
MCP serversAlways prefix with the server alias, so a name depends only on the server it came from and nothing is renamed when another server’s list changes
Custom toolsAbort. An injected tool may never shadow anything, and may not claim to be remote or MCP-backed

The a2a naming decision is a global pass over the whole set rather than a per-host one, and residual collisions are found by counting final names. Both make the outcome independent of discovery order.

Importing from an MCP server

Connection is per server, in configuration order, with each server’s own startup timeout covering transport setup and the handshake. A single failure closes everything already opened. The client advertises nothing: no roots, no sampling handler, so a foreign server cannot spend this agent’s model budget, and no elicitation handler.

A third-party tool descriptor is validated before it can reach the model API. A missing name, a schema that is not an object, a root type other than object, or a missing description each fail the import, because all definitions travel in one request and one bad descriptor would fail every call in the run.

A run import is strict: any server error or collision fails the run. The discovery path used by fisk info is lenient, connects to each server alone, closes as soon as names are read, and returns tools that are structurally not callable.

On a mid-conversation tools/list_changed rebuild, a collision is skipped and recorded rather than failing the run, because a third party is editing its own list.

Stdio children inherit the environment minus the credential union, rather than getting a replaced one, because a child with no PATH or HOME cannot run the servers operators actually wire up. Resolved URL secrets of eight characters or more are replaced everywhere they might be printed, longest first so a value containing another is replaced whole. Configured header names are dropped from any cross-host redirect.

Deferred loading

Past ten tools, counting deferrable plus built-ins, definitions are deferred and the model reaches them through tool search. Built-ins are never deferred. The threshold is re-evaluated per tool set, so a set that grows starts deferring and one that shrinks stops.

A tool set is immutable. A change arrives as a whole new set published to the source the loop snapshots.

Serving surfaces gate differently

Over MCP the calling client is asked through elicitation, and anything that is not an explicit approval fails closed. Over a2a, confirm-gated tools are dropped at selection time, because no operator stands behind a served call. Both surfaces refuse a deferred result: the answer would arrive against a session the path does not have.

functool.New enforces the matching rules at construction. A remote or MCP-backed tool may not also declare a confirm gate, since gating another party’s tool is not this process’s to do, and a tool that declares exposure may not be remote, MCP-backed or gated, because re-serving somebody else’s tool under this agent’s identity needs an operator’s explicit opt-in, and a spec has nowhere to record one.

Not yet wired

Nothing sets a2a exposure on a functool spec today, so only fisk command tools reach that surface. There is no a2a builtins allowlist, and declaring exposure without one would serve the tool the moment a2a is enabled, with no operator opt-in.

Next

Continue to Model providers for how a tool definition is rendered for the API, or Serving for the surfaces that hand these tools to somebody else.

Model providers

internal/llm describes a conversation in types that name no vendor. internal/llm/anthropic is the only package in the tree that imports the Anthropic SDK; every other package speaks the neutral types.

Where it lives

internal/llm holds the neutral model and the registry: types.go, request.go, response.go, provider.go, registry.go, middleware.go. internal/llm/anthropic holds the backend: provider.go, codec.go, tools.go. internal/llm/README.md is written as the contract a second provider must satisfy.

The neutral model

A Message is a role and a list of content blocks. ContentBlock is a union with exactly one of Text, Thinking, ToolUse, ToolResult or Provider set.

ThinkingBlock.Signature
A byte slice, because the neutral model never inspects or renders it. It only preserves it, and the model rejects a turn whose signature was dropped or altered.
ToolUseBlock.Input
Raw JSON, so arguments survive with no schema-shaped intermediate in between.
ProviderBlock
The escape hatch for server-side blocks the neutral model does not name: tool search results, web search results, redacted thinking. Kind plus faithful raw JSON.
SystemBlocks
A slice rather than a string, so a provider that supports separate system blocks can place a cache breakpoint on the last one.

ThinkingMode has three states: unset sends no parameter at all, where off sends the parameter set false. ReasoningEffort is a plain string rather than an enum, since the levels belong to the model and a newer one may take a level this build never heard of.

Usage carries five numbers, and Thinking counts inside Out rather than adding to it.

One call, end to end

llm.Requestneutral valuebuildParamscache, thinkingMessages.Newone blocking callblock codecone representationllm.Responseusage, stop reasonblock kinds the neutral model does not name survive byte for byte
A reply and a journaled turn go through the same block codec, so they share one representation.

There is no streaming. Call issues a single blocking request, and tool calls come back as ordinary tool_use blocks in the completed message. The provider is also the only enforcer of the per-call timeout.

buildParams is split out from Call so request assembly is testable without a wire call. It builds the system blocks fresh on every call, which keeps a cache-control marker out of the value hashed into the run fingerprint.

Prompt caching and thinking

buildParams places two cache breakpoints: the tools-and-system one on the last system block, and the conversation-tail one at request level. The TTL is one hour for an interactive run and five minutes otherwise, because an operator can sit thinking for longer than five minutes and come back to an expired cache.

Thinking blocks are stripped only when the mode is explicitly off. Stripping on unset would break the signature chain within a run, since the model emits thinking alongside tool_use and the next iteration has to echo it back. The stripper also returns the message untouched when everything would be removed, because an assistant turn with no content is rejected by the API.

A 400 from a call that sent thinking or a reasoning effort gains a remedy hint. For thinking the remedy is removing the block rather than setting it false, since false is still a parameter and is rejected the same way.

Load-bearing decision

Opaque payloads round-trip byte for byte. A thinking signature and a provider block’s raw JSON are preserved exactly, and a golden round-trip test guards it. The discriminator for an unnamed block is read from the marshaled JSON rather than from the SDK’s accessor, because the SDK leaves the type field at its zero value and fills the default only on marshal.

The codec also repairs a documented SDK round-trip defect: decoding a successful tool-search result drops a required field and selects the error variant, so the whole content union is rebuilt rather than patched.

Registration and identity

A provider registers itself from init with a name, a factory, and the environment variables that carry its secrets. That third argument is positional and required, so a provider cannot be registered without declaring them. The union across every linked-in provider is stripped from tool subprocess environments regardless of which provider is active.

The list names the secret-bearing variables only, not selector variables like a profile or config directory, which hold no secret and are guarded by file permissions.

Caps separates two names. Provider is the neutral id stamped into the run fingerprint; SemconvProvider is the name the OpenTelemetry semantic conventions use. The two vocabularies do not always agree and answer to different owners.

Load-bearing decision

Provider identity is a hard resume gate that --force cannot cross, and it is read off the resolved provider rather than the configuration, because an injected provider bypasses the registry. A stored thinking signature or provider block belongs to the provider that produced it.

Capabilities are declared rather than discovered, since neither Anthropic nor OpenAI exposes capability flags at runtime. Middlewares are net/http-shaped type aliases rather than defined types, so a provider SDK expecting the same function shape accepts them unchanged and the caller never imports the SDK to install one.

What is Anthropic-specific

The two-breakpoint cache scheme and its TTL choice, the adaptive summarized thinking display, the BM25 tool-search tool, forwarding extra schema keys verbatim through the SDK’s extension fields, and the stop-reason mapping, which is currently an identity cast because the values coincide. An unrecognized stop reason passes through rather than being lost.

Only a plain-text tool result decomposes into the neutral shape. An image or multi-block result is preserved as a provider block instead of being flattened.

Outstanding

Caps.MaxOutputTokens is declared and nothing clamps a request against it. OpenAI is the named next target, over the Responses API where its own tool search lives, with an explicit decision to hand-roll an HTTP client rather than take on an SDK whose types would leak back through the neutral layer.

Chat Completions needs one message per tool result where Anthropic batches them into a single synthetic user message; a system prompt as a plain string makes the cache-breakpoint mechanism meaningless; and the Responses thinking round trip pairs encrypted content with item ids, which may need more than the single opaque signature field.

Credential selection is hardwired to one variable today, so a second provider needs a per-provider convention before provider: openai stops requiring an Anthropic key.

Next

Continue to Memory for the first of the three stores, or Telemetry for what a call reports.

Memory

The model writes a note under a key and reads it back on a later run. The harness stores that text, shows it back, and never treats it as instruction.

Where it lives

internal/memory holds the contract: the Store interface, key rules, the write validator and the on-disk format. internal/memory/file and internal/memory/jetstream are the two backends. internal/toolkit/builtin/builtin_memory.go is the tool surface the model sees. Key files: store.go, key.go, write.go, frontmatter.go, scope.go.

The contract

Store has six methods and no Close. No backend owns a resource to release; the JetStream connection is borrowed from the host and must never be closed by the backend.

type Store interface {
	Info() Info
	List(ctx context.Context) ([]Item, error)
	Read(ctx context.Context, key string) (description, content string, err error)
	Create(ctx context.Context, key, description, content string) error
	Update(ctx context.Context, key, description, content string) error
	Delete(ctx context.Context, key string) (existed bool, err error)
}

Create returns ErrExists for a key that is already there. Update replaces what a key holds, and writes one that holds nothing yet on a backend that does not enforce read-before-update. The jetstream backend does enforce it by default, so an Update of a key this scope never read returns ErrStale rather than writing it.

An implementation must be safe for concurrent use by independent processes sharing one backing store, and must validate the key before touching that store. Info is a required method rather than an optional capability, so every backend reports its name and location.

Item
Key and description only. The body is always a separate Read.
Info.Backend
The registered backend name, which lands on a telemetry span.
Info.Location
An operator-configured identifier, never a filesystem path, a URL carrying userinfo, or a credential. The file backend returns an empty string for exactly that reason.
Scope
One run's record of which keys it has read and at which revision. A nil *Scope is valid and authorizes no overwrite, so every backend uses it without a nil check.

Limits live in store.go: 200 runes of key, 500 runes of description, 64 KiB of content, 1024 entries. MaxEntryBytes adds twice the description budget plus 64 bytes on top of the content cap, because YAML may quote and escape every byte of a description.

Writing a memory

  1. Validate before storing Every backend calls memory.ValidateWrite first: the key charset, the description after normalization, and the 64 KiB content cap. The normalized description is what gets persisted; the raw one is discarded.
  2. Count on create only An overwrite replaces an entry that already counted, so CheckCapacity runs on the create path alone.
  3. Serialize once memory.Serialize writes the YAML header with a real marshaller, so a description containing a colon, a quote or a leading dash cannot corrupt it.
  4. Store atomically The file backend stages a temp file and links it for a create or renames it for an overwrite. JetStream calls kv.Create, or the revision-checked kv.Update.
  5. Answer the model in its own terms ErrExists becomes a structured refusal that names the colliding memory's description, found by an extra read, so the model can decide without spending another tool call. ErrStale becomes an instruction to read the key and retry with overwrite: true.
memory_writemodel tool callValidateWritekey, size, descSerializeheader plus bodybackendCreate or Updaterun scopekey to revisionErrExists or ErrStale, carrying the reason the model needsgrants the overwrite
A write is validated in the shared package, stored by the backend, and refused with a reason the model can act on.

Keys and read-before-update

Legal keys match ^[A-Za-z0-9._=-]+$, the intersection of legal NATS KV key characters and safe filename characters. The slash is excluded so a key maps one to one onto a flat filename with no path separator to escape. ValidateKey also refuses a leading or trailing dot and any .., and every method in both backends calls it, including Read and Delete. The file backend re-validates when listing, so a hand-planted file whose stem is not a legal key stays invisible.

The JetStream backend requires a read before an update. Only Read records a revision in the run’s Scope. List and the start-of-run index also read values, but they read them to build an index rather than on the model’s behalf, so seeing a key in the index grants no authority to overwrite it. A successful create does grant it, since the model just wrote that value. A delete drops the revision, so a stale one cannot authorize an overwrite of a key that was re-created in between.

The scope is resolved per call rather than captured at construction, so one shared store serves many concurrent runs and each keeps its own. Across a suspend and resume the scope is stored in the journal: the runner writes Scope.Snapshot() as an optional record after the terminal record, replay takes newest-wins, and resume seeds the scope back. It survives a tool-set change, because revisions record what the store held rather than what an operator agreed to.

The two backends

filejetstream
Unitone .md file per key under memory/<identity>one KV value per key, <prefix>.<key>
Namespacinga directory per identitya key prefix, defaulting to the identity
Createos.Link, which fails if the name existskv.Create
Overwriteos.Rename, last write winsrevision-checked kv.Update by default
ErrStalenever returnedreturned when the scope knows no revision for the key, or the key changed since it was read
Listingread the directory, then one read per fileone server-side watcher pass, filtered to the prefix
Startup checkcreate the directory at mode 0700bind the bucket, reject a missing one, a TTL, or an undersized one
Info.Locationemptythe bucket name

The prefix option is a pointer so an omitted prefix, which defaults to the identity, stays distinguishable from an explicit empty string, which means a flat keyspace.

Load-bearing decision

Memory content is data, not instruction. The system note ends on that sentence, and the start-of-run index repeats it and fences the entries in a <memory-index> block. Descriptions are normalized to a single line at write time and passed through util.SanitizeForTerminal again at render time, which strips ANSI escapes as well. A value written by a hand-editing operator, or one written before the normalizer existed, is caught at render.

Load-bearing decision

The file backend opens with O_NOFOLLOW and then stats the returned descriptor, rejecting anything that is not a regular file. Content is read from that descriptor and never by re-opening the path, because a second open by name would resolve the path again and follow whatever was swapped in since. On Windows the flag is a no-op and the defense rests on the stat plus the privilege required to create a symlink.

Failing at run start

A JetStream bucket with any non-zero TTL is a construction failure, not a degraded run, because stored memories would silently expire. A positive MaxValueSize below MaxEntryBytes is refused for the same reason, and the check uses MaxEntryBytes rather than the content cap because the stored value is body plus frontmatter. A missing bucket produces a copy-pasteable nats kv add command. The backend binds and never creates, so the operator owns the durability policy. The bind runs under a ten second timeout, so a wrong bucket name surfaces at run start rather than hanging.

Strict option decoding is centralized in DecodeOptions, so an unknown key in a backend’s options block fails identically for every backend and the rule cannot drift. If the operator names a backend in config and an injected store reports a different one, the run refuses with an error naming both; naming no backend leaves the choice to the caller.

Read-only memory

With read_only set, the write and delete tools are not registered and the system note does not mention them, because the model spends a call on any tool it can see. The setting exists for a fleet endpoint that takes caller-supplied prompt text, which can otherwise be talked into planting something a later run reads back as its own note.

All four memory tools carry an empty ExposeSpec, which keeps them off the MCP and A2A surfaces. The builtin constructor panics on a nil spec, so that decision has to be made explicitly for every tool.

Next

Memory is what the model writes. For what the operator supplies, continue to Knowledge. For how revisions survive a suspend, see Durable state.

Knowledge

Knowledge is the corpus the operator supplies. One SQLite file holds the documents, the lexical index and, when embeddings are configured, the vectors. The user-facing name is knowledge everywhere: the config block, the CLI command and the tool names. Go identifiers use rag, because retrieval-augmented generation is the technique.

Where it lives

internal/rag holds the store and every operation on it. Key files: store.go for the lifecycle and format gate, index.go for the corpus walk, chunk.go, embed.go, search.go, enumerate.go, integrity.go, watch.go. internal/toolkit/builtin/builtin_rag.go is the tool surface; rag_command.go is the CLI.

The store

One file, knowledge.db, in the store directory. The driver is pure Go with no CGo, and the vector extension is linked in unconditionally so a lexical-only build is still one binary; the vector table is created only when embeddings are configured.

The file is created at mode 0600 before SQLite touches it, the directory at 0700, and the modes are re-asserted on the database and its write-ahead log and shared-memory sidecars afterwards. A symlink at any of those three paths is refused. Document text is stored verbatim and unencrypted, so a secret indexed is a secret on disk.

The schema is documents, chunks, two FTS5 tables, a vocabulary view and a metadata table. There are two FTS tables because FTS5 sets the tokenizer per table rather than per column: the porter-stemmed table answers every query, and the exact table exists to name the real words behind a stem and to be the one place a prefix search grows monotonically.

Chunk bodies and heading breadcrumbs are stored apart, so each is searchable without the other and a phrase cannot match across the join. Headings carry a 2.0 BM25 weight, set deliberately, where the old single table double-counted them by accident.

Indexing

  1. Plan the vector tier before writing anything The live model's dimension is probed and reconciled with the pinned manifest first, so a reindex against an unreachable embeddings server leaves the index whole.
  2. Walk the roots Skipping the store's own directory, dot directories, a sibling memory directory, symlinks, files over 512 KiB and anything that is not UTF-8.
  3. Classify by content hash A file whose SHA-256 matches the stored hash is skipped with its chunk count carried forward. A reindex bypasses that short circuit.
  4. Chunk on headings, pack by size A fenced code block is collected whole and never split. A heading flushes the section and updates the breadcrumb stack.
  5. Embed outside the write transaction The slow network call never holds the single writer slot.
  6. Replace the document's chunks in one short transaction Deleting the old rows fires the triggers that clear the FTS and vector rows, so a shrinking file leaves no ghosts.

Embedding requests batch sixty-four inputs and halve recursively on failure down to single inputs, preserving order. Responses are mapped strictly by each object’s own index with a seen-set: a gap, a duplicate, an out-of-range index, an empty vector, a count mismatch or an error object inside a 200 body fails the whole batch, so a vector never lands on the wrong chunk.

Orphan reconciliation runs only when asked, and only when the walk saw at least one file, so an early walk error cannot wipe the index.

Retrieval

queryfree textBM25 lexicalalways onvector KNNwhen embeddings setRRF fusionrank, not scorehitscitationsan embeddings failure degrades to lexical and says which step failed
Fusion is on rank alone, so the two tiers never need their scores normalized against each other.

Free text is reduced to OR-ed quoted terms: non-alphanumerics split, terms under two runes are dropped, internal quotes are doubled, and the list caps at forty. The lexical tier fans out fifty candidates. Reciprocal rank fusion breaks ties deterministically on chunk id, then the result is truncated to the requested count and hydrated in one join that preserves the fused order. A row that vanished between fusion and hydration, which a concurrent reindex can cause, is skipped rather than failing the search.

A citation is always the relative path and the chunk ordinal, and the same format is parsed back by knowledge show.

Degradation is classified by which step failed, never by an error’s text, and a deadline or cancellation takes precedence over the failing step because a hung server is both. A dimension mismatch is a real error rather than a degrade, since the answer would be wrong rather than narrower.

Enumeration is not ranking

knowledge_enumerate answers set membership: which documents hold these words. It exists because FTS5 booleans evaluate within a single row, and a row is one chunk, so "retention" AND "policy" misses a document holding one word in each of two chunks. Each term therefore runs as its own document-set query and the sets are intersected and subtracted in Go, holding only document ids.

The query language is small and closed: bare words, quoted phrases, a leading minus, and heading: or body: scoping. OR, AND, NOT and NEAR are rejected by name, because FTS5 would silently answer a different question; unguarded, foo OR bar compiled to foo AND or AND bar. A trailing * is rejected because a prefix query against a stemmed index can return fewer documents as the prefix grows.

Stems are computed with SQLite’s own porter tokenizer in a scratch in-memory database rather than a second Go implementation that would drift from the index.

Body and heading match counts are kept apart, because a single combined count inverted the ranking. The matched total is recorded before the limit is applied, so a budget never hides the size of the answer.

Honest status

The subsystem refuses to let an absence read as a result.

  • SearchStatus separates an index that was never built from one that is empty.
  • EnumerateStatus splits an empty corpus from an empty query, the two states the feature exists to tell apart.
  • The doctor report has a third state for a check that did not run, because a report that marks an unrun check as passing is false.
  • The enumerate tool emits its note on every call, since only a stated warning reaches the model.
  • Terms dropped for being too short are reported rather than discarded quietly.
  • knowledge match --exit-code is opt-in, because a complete empty answer is a successful answer.
  • The machine-readable vocabulary modes error rather than print nothing on an unbuilt index, so a pipe cannot read “not built” as “empty”.
Load-bearing decision

Retrieved text is data. The system note, the search tool description and the enumerate tool description each say that results are reference material the operator stored, never instructions, and that the paths they carry are data rather than targets for other tools. Everything from the corpus that reaches a terminal is sanitized and truncated, including vocabulary words, even though the current tokenizer emits only alphanumerics.

Format, locking and integrity

The format version is pinned in the metadata table and both directions are refused with no migration path: too new says to upgrade, too old says to reset and rebuild. The gate compares the pinned version and, separately, the column shape and object list, because a shipped reset once emptied the metadata table without changing the table shape, leaving no pinned version to compare.

A cross-process advisory write lock is held for a writer’s whole lifetime. On Unix it is a non-blocking exclusive flock, released by the kernel if the process dies, so a crash never wedges future indexing. On Windows it is an exclusive create, and a crash can leave a stale lock needing manual removal.

Writers set a single open connection, which is why every schema helper takes an executor and runs on the caller’s transaction: a nested begin would deadlock waiting for the one connection the caller holds. Readers cap idle connections and their idle time so no pooled connection pins a snapshot and blocks checkpointing across a long agent session. A read-only store is safe to share across concurrent runs, and the fleet server does exactly that.

Reset drops tables rather than deleting rows. Against an index whose FTS no longer matches its content table, deleting rows fails because the cascade fires the delete trigger into the broken index, where dropping a table fires no row triggers.

The integrity check is a write, because FTS5 commands are inserts, and only the rank form catches drift: the bare and rank-zero forms pass on an index that has already drifted. Rebuilding re-derives both indexes from the chunks table and re-embeds nothing. It stays an operator verb rather than an automatic repair, because against a corrupt content table it would build a consistent index over the corruption and the check would then pass.

Watching

Indexing runs on its own goroutine so a long pass never blocks event draining and overflows the kernel queue, and a dirty flag coalesces changes arriving during a pass into exactly one follow-up run. Events under the store’s own directory are ignored, since the write-ahead log and lock sidecars are written by the index pass itself.

Pending deletions are applied stat-guarded, so an editor’s atomic save, a transient rename the index pass has already re-added, does not drop a live file. Watch-descriptor exhaustion becomes one actionable warning naming the kernel limit rather than a fatal error.

Budget and exposure

The injection budget is the configured token count times four characters. Adding hits stops once the budget would be exceeded, but the first hit is always included, so a large first chunk is not silently dropped to nothing. Enumeration gets a quarter of that budget, because it is a pre-check that has to leave room for the retrieval that follows, with a floor of one document so a rounding to zero never reads as absence.

knowledge_search and knowledge_enumerate are the only MCP-exposable built-ins, gated per tool against the operator’s allowlist, so adding a tool to the knowledge set can never widen reach without a config change. Exposing only one of the pair is legal, and the run warns what a caller loses: set membership with no way to read the text, or retrieval with no way to check completeness.

Reserved

The documents.title column is written on every upsert and read by nothing; the matched-document type has no title field, so the write-only column stays unread. There is no migration path at all, by design. Indexing, the doctor and the watcher open no telemetry spans; only search, enumerate and the embedding batch do.

Next

Continue to Durable state for the third store and the journal a run is resumed from, or Memory for what the model writes rather than the operator.

Durable state

A run writes an append-only journal, and the conversation, the counters and the resume position are recomputed from the records, so they cannot drift from what happened.

Where it lives

internal/runstate holds the record model (record.go), the fold (state.go), the store contract (store.go), the shared append rules (validate.go), the resume gate’s fingerprint (fingerprint.go) and the embedded JSON schemas. internal/runstate/file and internal/runstate/jetstream are the backends. internal/tasks is a separate store for a different question.

Records and sequence

Ten protocol ids, one shape each, with the id in the body rather than in a subject or a filename, so one record read anywhere can be validated without knowing where it came from. A JetStream record body is byte-identical to a file journal line, so a run migrates between backends unchanged.

metaseq 1assistantseq 2tool_resultseq 3decisionseq 4terminalseq 5Foldpure, no IORunStatemessages, countersseq is the ordering authority: a duplicate is skipped, a gap is an error
Everything the resume needs is derived. Nothing is stored twice.

CheckAppend is the shared rule so the two backends cannot drift: at or below the last sequence is a duplicate to skip, one above is next, anything higher is a gap. Its contract says explicitly not to fold the advance into the helper, because the caller must advance only after the record is durably stored, so a torn write re-appends the same sequence instead of losing it. The file backend advances after fsync, the JetStream backend after the ack.

The file backend fsyncs every record and fsyncs the directory on a new journal’s first write. It drops an unparsable final line as a torn tail but treats an interior parse failure as corruption, which is only valid because the file is append-only and synced. JetStream enforces append-only at the server with one message per subject and a discard-new policy, and refuses a stream with a maximum age, since stored runs would silently expire.

Folding

Fold is pure. It requires the first record to be meta, requires the version to match exactly in both directions, and requires strictly increasing sequences. It walks the records keeping a current assistant turn, and commits that turn, appending the assistant message plus a synthetic user message of tool results, when the next assistant record begins or a user follow-up arrives. A trailing turn with unanswered tool calls becomes the pending batch instead.

Consecutive user turns are merged, because the API rejects two user messages in a row.

An optional flag carries forward compatibility, and may be set only where a reader that skips the record behaves more conservatively rather than differently. A record whose absence would change a decision in the permissive direction needs a version bump. A deferral record must never carry the flag. New fields are added with omitempty and a documented fold-as-zero rule instead.

The resume gate

The fingerprint records the configuration a stored conversation was written under, and each field is classed hard, blocking, tools or budget.

ClassFieldsBehavior
HardProviderRefused, and --force cannot cross it
BlockingModel, system prompt hash, thinking mode, reasoning effortRefused unless forced. Each can leave a history the provider will not accept
ToolsTool set hashNever refused. It drops standing approvals and warns, because a moved tool set invalidates the grants rather than the stored conversation
BudgetMax tokens, max iterationsReported only. A served conversation’s caller may lower both per request

The system prompt is stored as a hash, never verbatim. The resume reminder is appended to the prompt after the fingerprint is computed, so it can never perturb the comparison.

Claiming a run

A resume appends a claim record before it does anything else. The payload is diagnostic; the append moves the journal’s tail, so any worker that still believes it holds the run is refused at its own next append.

The claim lands before this worker causes any effect, and the last sequence is read after the claim, because reading it first would make the runner’s first record collide with the claim’s sequence and be folded away as a duplicate. A claim that fails is fatal to the resume, since skipping it when the store is briefly unreachable would leave a second worker free to append.

The fold treats a claim as completely inert: a claim written on resume lands between an assistant turn and the tool results answering it, so touching the current turn there would commit it early and destroy the pending batch the resume exists to finish.

The backends enforce it differently. The file backend takes an exclusive flock on a lock file for the journal’s lifetime, released by the kernel on exit, so a crash leaves no stale lock. JetStream publishes with an expected-last-sequence condition and disambiguates a rejection by reading the target subject: the same message id means a lost ack to adopt, sequence one means a concurrent creator, anything else means another writer.

Load-bearing decision

Standing grants survive a suspend, which is what they are for, but one-shot call approvals are cleared by a terminal record, so an approval the run never reached is spent rather than authorizing a later dispatch nobody approved. Neither record type carries a denial.

Load-bearing decision

No credential of this process reaches the journal. The system prompt is only ever a hash. The one credential stored is the caller’s conversation token: reading it requires store access that already grants writing the journal, it is never logged, and it is worth nothing without the identity that minted it.

Answering a deferred call

Supplying a result loads and validates before opening the journal, so a refusal costs no lock. It then writes one ordinary tool result record and nothing else, which makes the next resume an ordinary resume.

The check looks at the committed conversation first. Answering a deferral completes and commits its turn, so reporting it as never deferred would tell somebody answering twice that their first answer never landed.

The task record

internal/tasks stores what was asked and what came back. The journal holds how the work was done, which is private working state, and a caller is never sent there for an answer: not because it is hidden, but because depending on it would make every internal change a breaking one.

JournalTask record
ShapeAppend-only, folded over N entriesRewritten in place, two observable states
ContentsNeutral messages and this process’s working stateThe a2a messages verbatim
IdMinted or derivedTaken from the request, so one identifier threads request, record, trace and session
AudienceThis softwareThe caller

The state vocabulary reports only what the store can observe. Queued, claimed, running and retrying belong to the queue, and duplicating them would give two answers to one question. Completing refuses a second write, because with at-least-once delivery the loser is usually the failed worker and last-write-wins would let a failure replace an answer that succeeded.

Reserved and not yet wired

Nothing outside its own file backend imports internal/tasks yet, and there is no stream backend, though the registry hooks for one exist and the package doc names it.

The schema validator is exercised only by tests. Neither backend validates before writing and neither validates before folding, so an entry that violates its schema is written and folded without complaint.

Call approval records are read and spent but never written here: they are journaled by whatever supplied the operator’s answer while the run was suspended, and that out-of-band approval channel does not exist yet.

The two backends disagree in one documented case. The file backend folds every run to build its listing; JetStream rejected that as too expensive and summarizes from two records, so a run with a turn in flight is reported open by one and can be reported completed by the other.

Next

Continue to Serving for the surfaces that create these sessions, or The agent loop for what writes each record.

Serving

A served agent takes work from channels: peer agents over a2a, a work queue, or people in Slack threads. The server owns the run; a channel owns getting the work in and the answer out.

Where it lives

internal/serve is the host: serve.go holds the contract only, server.go the pool and lifecycle, resources.go the process-wide shared stores, endpoints.go the configuration-to-endpoint plumbing. The channels are serve/a2aendpoint, serve/asyncjobs and serve/slack. internal/a2a is the protocol, internal/mcpserver is a separate surface, and internal/conns owns NATS connection ownership.

Capability by supply

Channel is two methods, Name and Next, and says nothing about transport. What a channel can do is what it fills in on the Work it hands over. A channel that can put a question to a human supplies a prompter; one that cannot leaves it nil. There is no separate capability enum to disagree with reality.

Work.Done
Required, called exactly once, on a context that is not the run's, so a cancelled run still records what happened.
Work.RunContext
Called once, after the slot is acquired and immediately before the run. It is the only signal a channel gets that its work started.
Work.Budget
May only lower configured limits, because it comes from a caller the server does not control. The tool timeout is left alone however long it is, since both values come from whoever started the server.
Outcome
Reason plus rejected, abandoned, crashed and the deferred calls, none of which a reason can express.
a2a promptspeer agentsasyncjobswork queueslackpeople in threadsServera slot pool per channelWork in, Outcome outagent.Runone run per workOutcome, reported exactly once
Concurrency is per channel, because a channel that claims work before a run starts can only size its claiming to a limit it owns.

The puller takes work first and acquires a slot second, deliberately not the reverse, so a slot is never parked on an idle channel. If the context dies while waiting for one, the work is reported abandoned rather than dropped.

The three channels

a2a promptsasyncjobsSlack
IntakeA micro request, acked and handed over on a goroutineThe engine’s handler, which blocks because returning is the ackA socket envelope, decided in memory before the ack because of a three-second redelivery rule
PrompterOnly when elicitation is enabledNone. A queue has nobody to ask, so every gated tool is refusedButtons and inputs in the thread
EventsProtocol blocks on the reply streamNoneRecorded as state, written by a rate-limited publisher
FaultsReported, which drains the serverNot reportedReported
Default workers115, because a Slack turn is a person waiting

Session ids are always derived and never taken from a caller. All three hash their own inputs with a distinguishing prefix: a session id is not a secret, since it is logged and a deferred run’s terminal message carries it, so handing the store caller-chosen bytes would put every journal within reach of anyone who learned an id. The conversation field on an incoming request is dropped rather than passed through for the same reason.

Follow-up mode may only be set by a channel whose deliveries never repeat. Slack asks the store whether it already holds the thread rather than keeping a map, because a map gives the wrong answer after a restart, and a follow-up mistaken for an opening turn discards the person’s message.

Asking a person

The server substitutes a default-deny prompter for a nil one, because the run and the confirm gate both call the can-prompt method unguarded.

The a2a channel and Slack ask in the same way. The question goes out, the answer comes back on a subject or a thread the channel already watches, and a minted question id routes it to the call waiting for it. Each keepalive from the answering side restarts the waiting window rather than extending it by a fixed amount, so a caller can hold a question open for as long as somebody is typing.

Silence resolves differently by tool: the three question tools get a deferral, and the confirm gate gets an abort. A deferred call is never dispatched again, while an abort leaves the gated call to be re-dispatched on resume. Both channels therefore hold the operator’s approval separately, so the gate’s second question on that resume is answered from what was already decided.

A Slack question outlives the turn that asked it, so a click days later becomes a resume turn rather than reaching a dead run. Delivery and giving up are one transition under one mutex, so a click landing exactly as the window closes is reported as one or the other and never both.

Draining

Drain and stop are the same call at different times: close the hold once, then close every releasable channel and every service. The package registers no signal handler; the command layers the two-stage contract on top, where the first interrupt drains and the second cancels.

Each channel closes in an order chosen for what it must not lose. The a2a channel closes its shutdown gate first, so intake refuses rather than acking something nothing will run.

Slack waits for its intake to end, then edits the status message of every turn admitted but never started, since the server reports no outcome for work it never took. It waits for its posting goroutines so refusals still land, and closes the socket last, because turns already running are still receiving clicks.

The queue channel leaves one window open: the engine stores the answer after the handler returns, so a close landing in that gap costs one redelivery cycle. Not the work, since the session is already journaled.

A fault on any channel or service drains the server, because the drain unblocks a channel sitting in Next, and the error is returned so a supervisor restarts the worker.

One protocol, one discriminator

Every a2a message is a flat, self-describing JSON object, and nothing in a body says what kind of message it is: the protocol id is the discriminator, and one id names exactly one shape with one schema. Each schema names its own required fields and refuses its siblings’ by name.

Messages are validated on both sides in both directions. The client validates an outgoing message against the schema the receiver will use, so a message this agent could not have answered is refused here rather than arriving as a peer’s validation failure. The size cap runs first, before any decode or allocation.

Unknown properties are accepted and discarded, an unknown stop reason is carried rather than refused, and an unknown event kind validates against a framing-only fallback and decodes into a block that keeps the peer’s raw bytes. An unknown protocol id is rejected outright, because it decides what the message means and a receiver ignoring it would not know what it was ignoring.

Load-bearing decision

The engine dispatches from the protocol id in the body and never from the subject a message arrived on. Each subject carries exactly one message type, so a permission grant can cover one path without the others: tools without tasks, cancels without answers. An answer can approve a confirmation-gated command where a cancel only ends the run.

The request id is part of the cancel and elicit subjects, which is why its character set and length are constrained: a caller choosing those bytes freely would be shaping a subscription.

The reply set

A task’s reply is a set of messages, numbered from one, gap-free and monotonic per direction. The sequence advances only after a successful publish, so a message the sink refused reuses its number and the set stays gap-free. The size cap is enforced before the sink sees anything, which is why block text is trimmed rather than dropped: a dropped block would leave no gap for a caller to notice.

The ack is sequence one and goes out through the single-reply path, synchronously on the serving goroutine, so the transport measures the accept. Everything after it is published to the captured inbox. A refusal is always a negative ack followed by a terminal message, because the ack closes nothing and carries no code, and a caller holding only a refusing ack would wait to its own deadline.

Ownership passes between goroutines rather than being shared: the ack on the intake goroutine, events on the run goroutine, the terminal message from whoever reports the outcome. One owner at a time, no lock.

The cancel and elicit subscriptions are opened before the ack, because a cancel arriving at nobody is unfixable where a cancel arriving early merely waits.

A cancel does not cancel the run’s context. Somebody asking to stop is asking for a conversation they can continue rather than a turn that ended half done, so the run parks at its next boundary and ends suspended. Closing the stop gate does give up any question in flight, since a question is not a boundary.

The limit is on idle time rather than on the call, because a run may think for a long time and not be stuck. A configured value is floored at three keepalive intervals.

The MCP server

internal/mcpserver is not part of the serve tree and is not a channel. It is its own command with its own listener, its own concurrency limit and its own confirm policy, serving the same tool values with no model in the loop. It has no prompter: a tool that asks a question is denied, and a deferral is refused as a call the surface cannot carry. Human approval there is MCP elicitation, which fails closed on anything that is not an explicit accept.

The outbound MCP client runs on the serve path. Sessions are built once in the shared resources and reused by every run, so a stdio child starts once per worker rather than once per job. A run never opens or closes them, the resource close releases them first, and a drain deliberately leaves them alone.

The local run takes the same path

A terminal running fisk run without a NATS context still hosts an agent behind an embedded in-process broker and talks to it over a2a. A terminal reaches its own agent the same way it reaches somebody else’s, which makes the local path the one everybody exercises.

The hosted configuration is synthesized: exposure is replaced with a prompts-only block at one worker with elicitation on, so hosting a run at a terminal registers no micro service and takes no jobs. Only the channel gets the in-process connection; the stores and remote tools keep the connection the configuration named.

Nothing provisions storage. The queue, the task store, the session stream and the memory bucket are the operator’s to create, so a cluster nobody prepared fails at startup rather than being laid out by whichever worker started first.

Reserved

Several fields are carried and deliberately not acted on. Outcome.Rejected is always false because the caller name is recorded rather than enforced, and both consumers already handle it. Work.PromptWait and the bounded prompter have no in-repo user; they stay for an embedder’s channel that can reach an operator but cannot tell whether one is still there. Caller.Verified exists because no binding authenticates a publisher yet: NATS authenticates the connection to the server, not the publisher to the subscriber, so subject permissions are the whole of the access control.

On the protocol side, the must-understand flag, the parent header for multi-hop delegation, the instance half of an identity and the agent-call block are all defined, schema’d and unproduced. Two transport operations exist only to be named, and the second transport binding the interface exists for is not present.

No served run reaches an interactive turn boundary, because the serve path supplies no continuation. The startup banner is the one place the endpoint-agnostic layering is broken on purpose: it reaches each channel’s description by concrete type assertion, since the three return types were never unified.

Next

Continue to Telemetry for how a trace crosses these processes, or The terminal for the client on the other end.

Telemetry

internal/telemetry is the one facade every package reaches OpenTelemetry through. It imports the standard library and OpenTelemetry and nothing else from this repository, so it cannot join an import cycle, and its API takes primitives rather than domain types.

Where it lives

internal/telemetry holds the provider and lifecycle (telemetry.go), configuration resolution (config.go), every span kind (span.go), the attribute catalogue and closed vocabularies (attrs.go), instruments (metrics.go), content capture (content.go) and propagation (propagation.go). bootstrap maps configuration in; genai renders content documents.

Enablement is absolute

Resolution applies one precedence chain: an explicit off variable, then the caller’s own disable label, then the SDK’s disable variable, then on. The OTLP exporter variables never turn export on by themselves, so a host-wide collector endpoint cannot silently make every process on the box an exporter.

Validation runs only when export is enabled, so a stale endpoint in a file with telemetry off never fails a run. Content capture is forced off when export is off, because a privacy marker that overstates is as broken as one that understates.

Settings
Primitives only. SampleRatio is a pointer because zero is meaningful, and DisabledBy is a label rather than a bool so the library never names a flag it does not own.
Setting[T]
A value plus a display-only origin string, so fisk info can report every effective value alongside where it came from. Nothing branches on the origin.
Provider
Nil-safe on every method. A disabled run holds a nil pointer and call sites never branch on it. It registers nothing globally: no global tracer provider, no global propagator.
Delivery
Attempts against completions, tallied by exporters that wrap the real ones. OTLP is fire-and-forget, so a run whose every span was rejected would otherwise shut down with a nil error.

The shutdown context is built fresh with its own flush timeout rather than derived from the caller’s, so an interrupt cannot cancel the flush and discard the run worth reading.

Spans around a run

The root span is started with its finish deferred immediately, before the panic barrier, so that defers unwind last-in-first-out and the root observes the error the barrier substituted. The startup span keeps a context of its own that is never assigned back over the run’s, because doing so nested the whole run inside a span that ended at handoff.

caller processinvoke_agentthe runexecute_tool aliascaller sideinvoke_agent peerclient kindtraceparentpeer agentexecute_tool nameserver kinda remote parent claiming not sampled is not followed
The message body carries the trace context, so a delegation reads as one trace.

The sampler follows a remote parent that claims sampled, so a delegation stays one trace, but deliberately does not follow one claiming not sampled. The a2a body is unauthenticated, and any peer able to reach the subject could otherwise switch this process’s recording off. Trace ids are adopted either way, so linkage is unaffected, and nothing reads the incoming trace id as evidence of who called.

A tool span covers the whole handling, from before the registry lookup through every one of the exits, so a call that never ran is still a span. A model call span is finished from one place on both paths. The HTTP middleware creates no span at all: it adds one event per attempt to the chat span it was handed by identity, which is why the caller appends it last, so it sits innermost.

Two hard rules

Load-bearing decision

Nothing model-controlled reaches a span name or a metric attribute, and the error type stays inside a closed vocabulary. Span attributes cannot be set from outside the package; each span kind has one constructor owning its name and attribute set. ErrorClass is a struct rather than a string type, because a string type is convertible from any string and passing an error’s own text would compile and put absolute paths on a span.

span.RecordError is never called anywhere, because it records a stack trace. A failing span gets a class from the calling package’s own sentinels instead, and an unset class falls back to a generic value so a failure is never unfindable.

The same reasoning shapes the MCP server info type: it has two fields and no way to express a third, because a URL or a command argument carries credentials.

Conversation id, session id, tool call id, the model’s requested tool name and anything derived from content are on no instrument. Exit code, memory location and the served-call sender are span attributes only.

Metrics

The GenAI token and duration instruments come from the generated semantic-convention helpers, whose record calls take the required attributes positionally so the set cannot drift. Bucket boundaries are explicit for every instrument including those, because the SDK defaults are shaped for milliseconds and top out at ten thousand, which put every seconds-valued and token-valued observation into the first two buckets. That defect was invisible to every span assertion and was found by reading a real collector’s decoded output.

Token usage is recorded as input and output only. The cache and reasoning tiers are already part of the totals they sit under.

A degraded knowledge search gets a counter rather than relying on its span, because spans are head-sampled and metrics are not. Session appends report a caller-measured duration rather than opening a span each, since a per-append span would double a run’s span count.

Content capture

Four gates stand between a conversation and a collector: the config block, which is off unless it says otherwise; validation, which refuses plain HTTP to a non-loopback host while capture is on because the payload is now the secret; the provider, which carries the resolved setting; and the span, which is the only place a content builder is ever invoked and returns before invoking any when capture is off.

Five attributes carry content: the system instructions on the startup span, input and output messages on a chat span, and tool arguments and result on a tool span. The system instructions are recorded as the very last thing before the loop starts, because the prompt is not final where it looks final.

Messages are exported as a delta from a moving index rather than the whole conversation each call, since capturing everything per call is quadratic and a thirty-iteration run would ship thirty copies. When trimming, the newest suffix is kept and leading tool responses whose calls were dropped go with them, because orphaned ids render as tool output attributed to calls that never happened. The budget counts JSON-escaped cost, six bytes for a control character or an invalid UTF-8 byte, since a limit measured on the Go string does not limit the attribute.

Two payloads never leave the process: a thinking block’s signature and a provider block’s raw JSON. Both are replaced with an explicit omitted marker, so their absence does not read as an instrumentation gap.

Capture also changes the export shape: the batch size is derived from the per-attribute limit and gzip is enabled, neither of which applies when capture is off.

Conventions

GenAI span and metric attributes follow the last semantic-convention version that shipped them; the resource alone is built at a newer version, because merging refuses differing schema URLs and the SDK’s own detectors use the newer one. Convention keys are imported at the use site and never transcribed, so a version bump breaks the build rather than drifting quietly. Everything else lives under a fisk. prefix rather than squatting on the GenAI namespace.

Some conventions are declined deliberately. The startup span is not create_agent, enumeration is not retrieval, and the retrieval query and document attributes are permanently unused: the query attribute is flagged sensitive in the conventions, the document attribute is corpus paths, and capture already exports the same data one span up.

What an operator sees

With telemetry off, the bootstrap still returns a usable handle so callers never branch on nil, and if any exporter variable is set the command prints a note naming the variables and the switch responsible.

With telemetry on and nothing listening, OTLP is connectionless, so the run finishes normally. The counting exporters record attempts against deliveries and the first error, and the command prints how many of how many spans were delivered. An empty metric collection is dropped before the wire and not counted, because a collector answers 400 for a quiet run’s periodic export and counting it would report an attempt for a run that recorded nothing.

The startup card and the run summary read capture off the provider rather than the configuration, because a veto, a rejected endpoint or an embedder’s own provider makes those two disagree.

Reserved

The embedder-facing constructor and its options have no caller in this repository; they exist for a caller that already runs OpenTelemetry. One metric label, the session backend name, is a variable rather than a closed vocabulary. Clamping the name against the registered backends and dropping an empty one would close it.

Only run, mcp and serve bootstrap telemetry. The indexing commands start none.

Next

Continue to The terminal for the client a person drives, or The agent loop for what the spans wrap.

The terminal

The terminal is a client. Even a local fisk run hosts an agent behind an embedded broker and talks to it over a2a, so a terminal reaches its own agent the same way it reaches somebody else’s.

Where it lives

internal/tui holds the full-screen surface: viewer.go is the shared viewport, live.go drives a running agent, prompter.go is the native prompter, callline.go renders a tool call, splash.go the startup card. package main holds command registration and the two client surfaces, run_chat.go and run_client.go, with run_render.go as the single rendering point.

From the command to the screen

  1. Decide the shape A NATS context turns the process into a pure client. Without one it hosts. That single switch decides how much configuration the run needs.
  2. Resolve telemetry before anything is opened A bad endpoint then fails on a readable terminal rather than behind the alternate screen.
  3. Open one session store Shared by the hosted agent that journals into it and the channel that reads back from it.
  4. Start the agent before taking the screen A worker that cannot start says so on a terminal somebody can read.
  5. Probe the agent card One round trip with a short deadline. No responders is fatal, since nothing is serving that identity; any other failure returns no card and no error.
  6. Run the view Full screen when stdin and stdout are both terminals and nothing disabled it, otherwise the line surface.

On exit the terminal is restored and everything is reprinted to the normal buffer so it survives in scrollback: warnings, the handles of conversations a reset walked away from, the answer to stdout, and the resume hint, usage and trace lines to stderr.

One rendering, three sources

live reply seta2a blocksstored sessionsame blocksblockRendererone appearancelive viewportfull screenprintTranscriptplain outputone line list drives both, so a stored run reads like a live one
Every conversation this program draws goes through the block renderer.

Line kinds carry the prefixes that make the line surface and the full-screen viewport read the same: the arrow for a tool call, the reversed arrow for a result, the plain word for a warning. A tool result unwraps its command envelope, keeps a non-zero exit, and renders a silent success explicitly rather than as a blank.

With no terminal the plain surface drops tool output rather than folding it, since a text dump has nothing to unfold with.

Similarly, a tool call line is rendered from what the call is, not from what any one surface received, so live, streamed and journal-replayed runs produce the same line. Arguments are sorted, because a decoded object has no order and a line that changed between renderings would read as two calls.

Load-bearing decision

Model text goes through two steps before it can be drawn: terminal escapes are stripped, then literal brackets are neutralized so the text cannot open a color or region tag. Only after that are the trusted per-kind tags wrapped around it.

Chat and the line surface

Full screen--no-tui or no terminal
TurnsA loop: prompt, turn, input row, next turnExactly one
Empty promptOpens the input rowAn error naming the two ways out
Resume replay500 blocks, at or above the worker’s cap40, because nobody reads scrollback upwards
InterruptA key event, since the view holds raw mode; first press suspends at a boundary, second leavesFirst sends a cancel message, second stops
ThinkingFolded by defaultPrinted inline with --thinking

There is no --chat flag. Chat is implicit whenever the full-screen view runs.

Cancellation is a message rather than a signal, which is why the stop request is sent from a goroutine: waiting for the ack on the draw loop would freeze the view and swallow the second press.

An answer held for a question the run outlived is delivered between the turn and the input row, never riding on the next prompt.

The viewport

Lines, their plain text and their rendered markup stay index-aligned one to one, so search can address a line by index and rendering can replace only the new one. Search is authoritative over folding: a match inside folded content reveals it. Copying is not: folded content is left out rather than sent as its placeholder, because folding it says the reader is not reading it.

Folding applies to thinking only above a row estimate, and to tool output unconditionally when it is on. Tail-follow re-arms for the mouse, End and G but not for Down or Page Down, so reaching the bottom by any means behaves like following a file.

The key binding drops the scrollback and keeps the conversation; the slash command drops the conversation and keeps the scrollback.

The startup card is a single opaque text view rather than a flex layout, because a flex leaves its background unfilled and the transcript would bleed through. Its telemetry row has three states, since an agent that did not answer must not look like one that exports nothing.

The live view

The status bar is a small state machine: running, blocked, suspending, suspended, complete, aborted, error, awaiting input. Awaiting input is green and distinct from the amber block, and the state word survives on a monochrome terminal. Elapsed time is deliberately absent, because it kept climbing through idle input waits.

Four token counters are kept apart because a caller reports them apart and a resume seeds them; the bar renders their sum, since the budget counts cache reads and writes at full weight. Thinking tokens are tracked and never shown.

Standard error is redirected into a buffer for the whole run and flushed to the restored terminal afterwards, so SDK and library logging cannot draw onto the alternate screen. That is also why the debug flags write fixed-name files rather than to stderr, each created exclusively after an unlink so a planted symlink is dropped rather than followed.

Teardown ordering is owned by one goroutine so nothing marshals onto a stopped loop, and the screen restore is idempotent, because the framework calls it on stop and again from its own panic recovery while the viewer defers one of its own.

Load-bearing decision

An aborted prompt records no decision. A checkpointed run would otherwise replay an answer the operator never gave, on every resume.

Only one question is put at a time, whichever surface asks, because the contended resource is the terminal rather than either widget. In the full-screen prompter the modal owns its keys, but the interrupt still reaches the run, so leaving is always possible with a prompt up. In the line surface the approval list puts No first, so a reflexive Enter declines.

Telling a multiplexer what is happening

internal/multiplex reports whether the run is working, idle, or blocked on a decision, so a multiplexer arranging agents in panes can show which one wants a person. It cannot tell any of that from the pane’s output, so the agent says it.

Reporting is best effort and never fails a run. Every call returns before the report is sent, a newer report supersedes an unsent older one, a failed delivery is dropped, and the sender recovers its own panics, because a panic there would take the process down with the terminal still in raw mode.

The pane is labeled with the agent’s identity rather than the program name, since somebody watching six panes is watching six agents. Detection reads the environment and claims the process for the first multiplexer that named itself. Outside a pane no multiplexer claims it, and the caller installs the same option either way.

The hooks are driven from what the a2a client already sees. Working fires on prompt submission rather than on the turn being accepted, because an agent under load can take seconds to ack and the pane would ask for a person while the work is already on its way.

Reserved

The short form of a tool call line is infrastructure waiting for a wire field. The renderer sets it equal to the full text, so the fit test can never choose it; the pre-elided form exists inside the agent but the protocol’s tool-call block carries only the name and input.

The live status bar’s session segment never renders, because the run command sets no title. The header’s chat marker described in the source does not exist. Stale references to the removed --chat flag survive in three comments. The multiplexer detector table has one entry, and the blocked-reason plumbing is general but fed only by the question path.

Next

Continue to Reference for the command surface and the source map, or Serving for the agent on the other end of the wire.

Reference

Seven commands, registered in main.go, plus the packages behind them and the words this codebase uses for its own parts.

Commands

fisk run [query...]

Hosts an agent and talks to it, or talks to somebody else’s.

FlagEffect
--configConfiguration file, default agent.yaml
--api-key, --base-urlProvider credentials and endpoint
--nats-contextTurns the process into a pure client of a remote worker
--identityNames the agent to reach; requires --nats-context
--resume, --forceContinue a session id or conversation token, optionally across a changed configuration
--no-tui, --no-color, --verbose, --tool-output, --thinkingPresentation
--trace, --http-debug, --a2a-debugDiagnostic files
--state-dir, --no-telemetryStorage and export

Two validation gates run before anything opens. fisk run refuses --resume with a query, --force without --resume, and --identity without --nats-context. In client mode it also refuses seven flags belonging to the worker, but only when they are set on the command line, so an exported environment variable never fails a run.

There is no chat flag. Chat is implicit whenever the full-screen view runs.

fisk serve

Runs the endpoints the configuration enables, with --workers, --work-dir, --state-dir, --api-key, --base-url, --no-telemetry and --verbose. It refuses a configuration that enables no endpoint, and provisions no storage: the queue, task store, session stream and memory bucket are the operator’s to create.

fisk mcp

Serves the selected tools over MCP, with --port and --address defaulting to loopback. It refuses a configuration with no MCP exposure block.

fisk knowledge (aliases rag, k)

Group flags --config and --store-dir.

SubcommandPurpose
index [paths...]Build or update the index. --reindex, --dry-run
watch [paths...]Re-index on change. --debounce, --no-initial
search <query>Ranked retrieval. --top-k, --full
match <query> (aliases enumerate, which)Complete set membership. --all, --count, --paths-only, --explain, --exit-code, --min-matches, --sort, --limit
words (aliases vocab, terms)Vocabulary with document frequencies. --field, --min-docs, --max-docs, --words-only, --count, --exit-code
show <citation>One chunk by path and ordinal
rm <sources...>, reset --forceRemove documents, or the whole index
sources, stats, doctor, rebuildInspect and repair

fisk session

ls, show <id> and rm <id>, with --config and --state-dir. The show subcommand takes --transcript, --thinking and --no-tui.

fisk info

Reports the effective configuration, including telemetry values with their origins. It parses in the most lenient mode so it can describe a configuration it could not run.

fisk discover <agent>

Fetches a peer’s agent card over the configured NATS context.

Source map

PathHoldsPage
main.go, run_*.go, *_command.goCommand registration, flags, the two client surfaces, renderingThe terminal
config/config.goEvery configuration type, the parser, defaults, validation, accessorsConfiguration
internal/agentRun setup, the loop, hooks, events, approvals, the PII guardThe agent loop
internal/toolkitThe tool interface, tag behavior, the prompter, deferralTools and introspection
internal/toolkit/fiskCLI introspection into tools, subprocess executionTools and introspection
internal/toolkit/functoolGo function tools, the backend for built-ins, remote and MCPTools and introspection
internal/toolkit/builtinThe harness’s own tools and their system notesTools, Memory, Knowledge
internal/mcpclient, internal/remotetoolsImporting tools from MCP servers and peer agentsTools and introspection
internal/llm, internal/llm/anthropicThe neutral conversation model and its one backendModel providers
internal/memoryThe memory contract, key rules, scope, two backendsMemory
internal/ragThe knowledge store, indexing, retrieval, enumerationKnowledge
internal/runstate, internal/tasksThe journal, the fold, the fingerprint, the task recordDurable state
internal/serve and subpackagesThe host, the three channels, shared resourcesServing
internal/a2aThe protocol, its schemas, the client and server, the NATS bindingServing
internal/mcpserverThe MCP tool server, a surface of its ownServing
internal/telemetrySpans, metrics, content capture, propagationTelemetry
internal/tui, internal/multiplexThe full-screen view and multiplexer reportingThe terminal
internal/piiPersonal-data detection and redactionThe agent loop
internal/conns, internal/utilNATS connection ownership, shared helpersArchitecture
internal/agenttestFakes for an embedder’s testsArchitecture

Key types

TypePackageWhat it is
ConfigconfigThe whole of agent.yaml, parsed, defaulted and validated for a mode
TooltoolkitNine methods every tool kind answers, whatever provides it
BehaviortoolkitWhat a tool declares about itself, resolved conservatively
PromptertoolkitThe only path permitted to read the terminal or draw a prompt
toolSetagentAn immutable set of definitions plus the registry that dispatches them
HooksagentSeven points a caller can observe or interrupt
ProviderllmCall and Capabilities: the only code that calls a provider SDK
ContentBlockllmThe neutral union, with a provider block as its escape hatch
StorememorySix methods, no close, safe across processes
StoreragThe knowledge database and every operation on it
Record, RunStaterunstateOne journal entry, and the fold of all of them
FingerprintrunstateThe configuration a stored conversation was written under
Channel, Work, OutcomeserveWhere work comes from, what it is, and what came of it
Headera2aThe framing embedded flat into every protocol message
ProvidertelemetryNil-safe spans and metrics, registering nothing globally

Glossary

Identity
The agent's name: a NATS subject token, a queue group, and the first field hashed into a session id.
Named identity
One an operator wrote, as opposed to one derived from the application's basename. Every serving path requires a named one.
Introspection
Running a fisk application with a flag that makes it describe its own command tree, which is where tool schemas come from.
Confirm gate
The default-deny check in front of a tagged tool. Independent of the human-in-the-loop tools.
Standing grant
An approval that covers a tool for the rest of the conversation, journaled after the call that triggered it resolves. There is no standing denial.
Deferral
A tool saying it will answer later. The call is never dispatched again; its turn finishes when an answer is supplied.
Elicitation
Putting a question to a person through the caller rather than at a terminal, over a2a or MCP.
Fingerprint
The stamped configuration that decides whether a stored conversation may continue, and whether its approvals survive.
Claim
A record appended on resume. Appending it excludes a second runner; the payload only says who took the run.
Reply set
The sequence of messages answering one a2a request, numbered gap-free from the ack.
Conversation token
The caller's credential for adding a turn to a conversation. Neither logged nor displayed.
Tool search
Deferring tool definitions past a threshold so the model looks them up instead of receiving them all.
Knowledge
The operator's corpus. Go identifiers call it rag; every user-facing name calls it knowledge.
Memory
Notes the model writes and reads back. Always data, never instruction.
Degrade
A knowledge search that fell back to the lexical tier, reported with which step failed.
Content capture
Exporting prompts and completions on spans. Off by default, and it bypasses the other protections in that area by construction.
Next

Return to the overview, or read Architecture for how these packages layer.