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.
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.
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.
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
Concern
Enforced by
Which tools exist
The flat namespace built at run start; collisions abort
Which tools the model may see
Config include and exclude, after ai:deny is stripped unconditionally
Which tools need a human
The confirm gate, on the union of the original and rewritten call
Which tools reach a peer
The exposure methods on the interface, plus a per-surface allowlist
Whether a conversation may continue
The run fingerprint, split into hard, blocking, tools and budget classes
What may leave the process on a span
Closed 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.
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.
Unmarshal strictlyDisallowUnknownField 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.
Prepare Derive the identity, normalize enumerated strings, parse every duration into a matching ...Parsed field, and apply defaults. This step mutates in place.
Validate for a mode The checks that always run, then the ones the calling command needs.
One parser, three validation modes. The mode is chosen by the command, not written in the file.
What each mode requires
Mode
Used by
Requires
ModeMCP
mcp, info, knowledge, discover, session, and run against a remote worker
The always-on checks and nothing more
ModeAgent
run hosting its own agent
llm.model; identity and system prompt unless the run is MCP-only; a NATS context alongside remote_tools or jobs
ModeServe
serve
Per 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.
Key
0s means
harness.tool_timeout
Unbounded. The operator asked for no limit
mcp_clients[].timeout
Rejected. An unlimited startup holds the whole run against a server that never answers
expose.agent.a2a.request_timeout
Rejected. 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_grace
Rejected. It would defer every question the instant it was asked
llm.budget.call_timeout
Rejected. 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
Stage
Examples
Parse
An 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 startup
fisk 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
Run
A 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_clientsenv, 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
Poll for suspend Only at the loop boundary, and before the iteration index is consumed, so a suspend does not burn one.
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.
Call the model Under the per-call timeout the provider owns.
Journal the assistant turn before running any tool A crash mid-batch resumes without paying for the model call a second time.
Execute each tool call Validate arguments, apply the confirm gate, trace, dispatch, journal the result.
Feed the results back All results become one user message and the loop iterates. If anything deferred, nothing is appended and the run suspends.
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:
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.
Describe and check gating on the original call Done before any hook, so PreToolUse sees what the model actually asked for.
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.
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.
Confirm gate Fires if either the original or the effective tool is gated.
Trace and dependencies The tool receives a prompter or a work directory only if it said it needed one.
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.
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
Limit
Scope
Zero means
llm.budget.max_output_tokens
One model reply. Defaults to 8192, raised to 16384 when thinking is on
The built-in default
llm.budget.max_tokens
Cumulative across the conversation: input, output, cache reads and cache writes
Unbounded
llm.budget.max_iterations
An absolute position, grown by the configured amount on each accepted follow-up
Refused
llm.budget.call_timeout
One provider call, enforced inside the provider
Refused
harness.tool_timeout
One tool call, as a context deadline
No 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.
Hook
Fires
Can
RunStart
Once, before a session is created or opened
Abort
UserPromptSubmit
On each prompt entering the conversation
Deny, rewrite
PreModelCall
Before each model call, above the provider
Abort
PostModelCall
After each reply, including a truncated one
Abort, but not durably: the turn is already journaled
PreToolUse
Before validation, gate, trace and execution
Deny, rewrite the tool and its arguments
PostToolUse
After execution, before the trace and journal
Replace the output
TurnEnd
At an interactive continuation boundary
Abort
RunEnd
At teardown, after stores close, including on a crash
Nothing
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
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.
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.
Tag
Effect
ai:deny
Stripped before include and exclude run, and it can never be added back on any surface
ai:no_defer
Always sent to the model directly, never hidden behind tool search
ai:confirm
Always gated, matched unconditionally, so leaving it out of confirm_tags cannot weaken it
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.
Source
On a clash
Built-ins
Abort the run, naming the tool to exclude or rename
a2a peers
Prefix 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 servers
Always 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 tools
Abort. 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
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.
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
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.
Count on create only An overwrite replaces an entry that already counted, so CheckCapacity runs on the create path alone.
Serialize oncememory.Serialize writes the YAML header with a real marshaller, so a description containing a colon, a quote or a leading dash cannot corrupt it.
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.
Answer the model in its own termsErrExists 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.
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
file
jetstream
Unit
one .md file per key under memory/<identity>
one KV value per key, <prefix>.<key>
Namespacing
a directory per identity
a key prefix, defaulting to the identity
Create
os.Link, which fails if the name exists
kv.Create
Overwrite
os.Rename, last write wins
revision-checked kv.Update by default
ErrStale
never returned
returned when the scope knows no revision for the key, or the key changed since it was read
Listing
read the directory, then one read per file
one server-side watcher pass, filtered to the prefix
Startup check
create the directory at mode 0700
bind the bucket, reject a missing one, a TTL, or an undersized one
Info.Location
empty
the 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
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.
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.
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.
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.
Embed outside the write transaction The slow network call never holds the single writer slot.
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
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.
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.
Class
Fields
Behavior
Hard
Provider
Refused, and --force cannot cross it
Blocking
Model, system prompt hash, thinking mode, reasoning effort
Refused unless forced. Each can leave a history the provider will not accept
Tools
Tool set hash
Never refused. It drops standing approvals and warns, because a moved tool set invalidates the grants rather than the stored conversation
Budget
Max tokens, max iterations
Reported 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.
Journal
Task record
Shape
Append-only, folded over N entries
Rewritten in place, two observable states
Contents
Neutral messages and this process’s working state
The a2a messages verbatim
Id
Minted or derived
Taken from the request, so one identifier threads request, record, trace and session
Audience
This software
The 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.
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 prompts
asyncjobs
Slack
Intake
A micro request, acked and handed over on a goroutine
The engine’s handler, which blocks because returning is the ack
A socket envelope, decided in memory before the ack because of a three-second redelivery rule
Prompter
Only when elicitation is enabled
None. A queue has nobody to ask, so every gated tool is refused
Buttons and inputs in the thread
Events
Protocol blocks on the reply stream
None
Recorded as state, written by a rate-limited publisher
Faults
Reported, which drains the server
Not reported
Reported
Default workers
1
1
5, 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.
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.
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
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.
Resolve telemetry before anything is opened A bad endpoint then fails on a readable terminal rather than behind the alternate screen.
Open one session store Shared by the hosted agent that journals into it and the channel that reads back from it.
Start the agent before taking the screen A worker that cannot start says so on a terminal somebody can read.
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.
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
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
Turns
A loop: prompt, turn, input row, next turn
Exactly one
Empty prompt
Opens the input row
An error naming the two ways out
Resume replay
500 blocks, at or above the worker’s cap
40, because nobody reads scrollback upwards
Interrupt
A key event, since the view holds raw mode; first press suspends at a boundary, second leaves
First sends a cancel message, second stops
Thinking
Folded by default
Printed 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.
Flag
Effect
--config
Configuration file, default agent.yaml
--api-key, --base-url
Provider credentials and endpoint
--nats-context
Turns the process into a pure client of a remote worker
--identity
Names the agent to reach; requires --nats-context
--resume, --force
Continue a session id or conversation token, optionally across a changed configuration
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.
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 --force
Remove documents, or the whole index
sources, stats, doctor, rebuild
Inspect 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
Path
Holds
Page
main.go, run_*.go, *_command.go
Command registration, flags, the two client surfaces, rendering