Extensions #
fen has a small Lua/Fennel extension system for adding behavior around the core agent loop without patching packages/core/ directly. Extensions can add slash commands, tools, hooks, system-prompt fragments, event subscribers, and presenters.
The extension system is intentionally a soft plugin boundary: extensions run inside the same Lua VM as the agent and have normal host access. Install only extensions you trust.
Mental model #
There are three pieces:
| piece | path | role |
|---|---|---|
| Extension API / registries | packages/core/src/fen/core/extensions/ | Event bus, command/tool/presenter registries, UI slot, lifecycle dispatch |
| Extension loader | packages/core/src/fen/core/extensions/loader/ | Walks roots for extension entries, checks deps, loads/reloads each spec |
| Extension source | extensions/adapters/ and extensions/behaviors/ (first-party) or user config dirs | Contributes behavior by calling the API |
An extension is usually a directory with an entry and an optional manifest:
my-extension/
manifest.fnl # optional metadata for the loader
init.fnl # runtime registration entrypoint (default; manifest can override)
Short version:
manifest.fnl = how to load/reload/describe the extension (optional)
init.fnl = what the extension actually does once loaded
The fen.extensions.* namespace is a convention for first-party rocks, not a structural requirement. Third-party extensions may pick any namespace, and project-local drop-ins need no namespace at all — the manifest or local entry file decides.
First-party source is organized by role, not by register kind:
extensions/
adapters/ # providers, presenters, session storage backends
behaviors/ # commands, tools, prompt fragments, panels, controls, hooks
Within behaviors/, actions/ contains command-oriented workflows such as conversation/session lifecycle commands. These are not session adapters: the adapter is the storage backend under adapters/session-backends/, while behaviors/actions/sessions/ is the frontend behavior that calls whichever backend is active.
Adapters connect fen to a substrate or backend. Behaviors add agent/user-facing capabilities and may register any mix of commands, panels, tools, status items, prompt fragments, controls, and hooks. For example, behaviors/inspectors/queue owns /queue, /cancel-all, panel rendering, and persistent panel state as one cohesive behavior; the queue data itself lives in the steering queue service it renders.
Discovery #
Extensions are discovered by walking known roots for extension directories and single-file entries. Internal first-party extensions are loaded from the embedded manifest registry. First-party overlay roots are walked recursively so the in-repo adapter/behavior taxonomy can be used during source-checkout development. Project and user drop-in roots remain shallow and flat.
Roots, in priority order (first match wins per name):
- Explicit
--extension <path>flags — single extension dir or single file - Trusted first-party flat overlays —
--extension-root/FEN_EXTENSION_ROOT, used by the single-file launcher for source-checkout first-party extension development - Project-local drop-ins —
.fen/extensions/in the current directory and each ancestor up to the first.git/.hgworktree marker (or filesystem root if no marker exists) - User config drop-ins —
$FEN_EXTENSIONS_PATH(colon-separated explicit roots) and${XDG_CONFIG_HOME:-~/.config}/fen/extensions/ - Internal first-party extensions — known embedded manifest modules from the runtime ZIP/module searchers
Project-local fen/extensions/ is intentionally not an implicit filesystem root. Use .fen/extensions/ for project drop-ins, ${XDG_CONFIG_HOME:-~/.config}/fen/extensions/ for user-global drop-ins, or name another root explicitly.
For project and user drop-ins, discovery is shallow: only direct children of a root are considered. A candidate may be either:
- a directory containing
manifest.{fnl,lua}orinit.{fnl,lua}(the typical shape) - a single file
foo.fnl/foo.lua— the file is the entry and the extension name comes from the basename
First-party extensions are enabled unless their manifest sets :enabled-by-default false. The structured todo companion is off by default; enable it with /extensions enable todo or "extensions": {"todo": {"enabled": true}} in settings. This restores todo_write, /todos, and its status/panel views. Plan and goal remain enabled by default. Project-local extensions are enabled by default even without :enabled-by-default true, because placing an extension under a project's own .fen/extensions/ is treated as intent to run it. User-global discovered extensions still honor :enabled-by-default. Explicit --extension <path> always loads regardless of that field and wins name dedupe over a disabled first-party spec.
The extensions object in ~/.config/fen/settings.json holds per-extension settings keyed by extension name, inheriting from optional defaults.json:
{
"extensions": {
"decide": {"enabled": true, "model": "~typesafe/jev-latest"}
}
}
A boolean enabled overrides the manifest's :enabled-by-default and the project-local rule for every source except explicit --extension, which still always loads. An absent enabled keeps the rules above. Changes take effect on /reload or restart; a disabled extension's contributions are removed through the same owner cleanup reload uses. /extensions enable <name> and /extensions disable <name> write that flag, preserving every other settings key, and then run /reload. They accept listed extensions, including discovered-but-disabled ones, and reject unknown names. /extensions disable refuses the active presenter and the current owners of /reload and /extensions, to avoid breaking the running UI or its recovery commands. To override this guard deliberately, edit settings.json directly and reload or restart; settings-based disablement remains unrestricted. The model-facing extension tool cannot change it: enabling code stays a user decision. /extensions marks a status decided by settings rather than the manifest with (settings). The remaining keys are the extension's own settings, read with api.settings.extension. An explicit extension directory inside a trusted first-party overlay root keeps first-party trust, so it may register privileged kinds. From a source checkout (scripts/dev/fen-dev or scripts/test/fen-src), enable the test-only mock provider with --extension extensions/adapters/providers/mock --provider mock. Enable the contributor-only model-facing profile tool with --extension extensions/behaviors/inspectors/profiler-tool; the /profile command is on by default. The fen source checkout's .fen/extensions/fennel-eval/ development escape hatch remains inert unless FEN_FENNEL_EVAL=1 is set.
In the canonical source-checkout workflow, scripts/dev/fen-dev prepends extensions to FEN_EXTENSION_ROOT for the single-file runtime. First-party flat extensions are loaded directly from .fnl source under the taxonomy directories; no rebuild or dist/ mirror is required for reload-driven development.
Hidden and underscored entries in project-local roots are skipped silently:
.foo/ # skipped
_foo/ # skipped
.foo.fnl # skipped
_foo.lua # skipped
foo/ # considered
foo.fnl # considered
If both foo/ and foo.fnl/foo.lua exist in the same root, the directory wins. Duplicate names across roots are first-seen-wins; later duplicates are skipped silently.
Manifest #
Directory extensions may declare a manifest.fnl (or manifest.lua) at their root. A Fennel manifest is just a table:
{:name :hello
:description "Example extension"
:enabled-by-default true
:entry-module :fen.extensions.hello
:requires-modules [:lfs]
:reload-modules [:fen.extensions.hello.helper
:fen.extensions.hello]
:reload-exclude [:fen.extensions.hello.state]}
Fields:
| field | meaning |
|---|---|
:name | Extension owner/name used for introspection and teardown. Falls back to dir name. |
:description | Human-readable description. |
:enabled-by-default | Whether discovered extensions load automatically; see Discovery for per-source rules. Explicit --extension always loads. |
:entry-module | Lua module name resolved through require. The module should return a register function, or a table with :register. Used by first-party extensions and rock-shaped installs. |
:entry | File path relative to the manifest dir. The file is dofile'd and should return a register function, or a table with :register. Used by path-shaped (project drop-ins, single-file). |
:requires-modules | Lua modules probed with require before loading; missing ones fail the load with an install hint (see Packaging and dependencies). |
:requires-shared-libs | System libraries named in missing-module diagnostics only. |
:reload-modules | Module names to clear from package.loaded on reload. |
:reload-exclude | Module names to preserve even if listed or otherwise known. Use for persistent state. |
:interactive-only? | Load only in the interactive (TUI) load pass, not the early non-interactive pass that headless runs and provider resolution use. |
:presenter | Load only when this presenter is active (for example :tui); omit to load under any presenter. |
If no manifest is present, the loader uses the directory basename as the extension name and falls back to <dir>/init.{fnl,lua}. If a manifest is present but neither :entry-module nor :entry is set, the same init fallback is used.
The loader records disabled, missing-dependency, loaded, and error states for /extensions and api.list :extensions.
Discovery and reload code may receive an optional cooperative yield callback from the presenter runtime. Long filesystem walks, manifest scans, and subprocess drains should call it between chunks or directory entries. The callback may raise for cancellation, so discovery helpers must close pipes and files before rethrowing.
Entrypoint shape #
One entrypoint shape is preferred: the loaded entry returns a register function that receives api. A manifest can point at either a file (:entry) or a module (:entry-module); without a manifest, the init.{fnl,lua} file fallback is used. The loader creates the API, removes prior owner-tagged contributions, and invokes the register function, so normal extensions do not need to call unregister-by-owner themselves:
(fn [api]
(api.register :command
{:name :hello
:description "Say hello"
:handler (fn [_args _ctx]
(api.emit {:type :info :text "hello"}))}))
A table with :register is also honored and is useful when a module wants to expose helpers for tests:
{:register
(fn [api]
(api.register :command
{:name :hello
:handler (fn [_args _ctx]
(api.emit {:type :info :text "hello"}))}))}
Lua extensions work too:
return function(api)
api.register("command", {
name = "hello",
description = "Say hello",
handler = function(args, ctx)
api.emit({ type = "info", text = "hello" })
end,
})
end
File-backed extensions can load sibling files via (api.load :state) — resolved relative to the manifest dir, no namespace required:
(fn [api]
(let [state (api.load :state)] ; loads ./state.fnl or ./state.lua
(api.register :command
{:name :hello :handler (fn [] (state.greet))})))
An entry that returns neither a register function nor a table with :register fails to load. API construction is loader-owned; extensions never construct an api directly.
API surface #
The API table passed to an extension contains:
| method / field | purpose |
|---|---|
api.register(kind, spec) | Register public contribution kinds: tools, commands, controls, hooks, status items, or panels. |
api.on(event-name, handler) | Subscribe to event bus events. :* receives all events. |
api.emit(event-table) | Publish an event. |
api.log(level, message-or-table) | Persist an owner-tagged diagnostic record and mirror it to stderr when FEN_LOG allows the level. |
api.prompt(text-or-fn, opts) | Add system-prompt fragments. |
api.list(kind) | Frozen introspection lists. |
api.introspect | Introspection helpers: collect. |
api.actions | Privileged harness/headless action helpers: list, invoke. |
api.commands | Command helpers: dispatch. |
api.turn | Turn helpers: submit!. |
api.enqueue(kind, text, opts?) | Queue a :steering or :follow-up message through the interactive runtime. |
api.auth | Auth backend helpers: find-backend. |
api.session | Active session helpers: active-backend, set-info!, info, append-state!, latest-state. |
api.diagnostics | Diagnostic helpers: list-errors, error-log-path. |
api.settings | Settings proxy: set-defaults!, set-thinking-default!, and extension, which returns this extension's extensions.<name> settings (without enabled) or {}. It re-reads defaults.json and settings.json on each call, so it follows /reload; apply your own defaults. |
api.models | Model registry proxy: list, resolve, canonical-id. |
api.ui | Active presenter UI slot helpers: has-ui?, notify, prompt, select. See UI helper fallback behavior. |
api.load(name) | File-backed extensions only: load <manifest-dir>/<name>.{fnl,lua} and return its value. Use for sibling files without a namespace. |
For third-party extensions, this API table is the compatibility contract. Raw fen.core.* requires are private implementation details unless explicitly surfaced above. First-party in-tree extensions may still require internals when there is no public equivalent, but should prefer api as the boundary.
api.register has a public and privileged kind split. Public extensions may register :command, :tool, :hook, :input-handler, :status, :panel, :control, :introspect, and :action. Infrastructure kinds :provider, :auth-backend, :session-backend, and :presenter are reserved for first-party extensions (embedded, trusted overlay roots, or explicit directories inside those roots) until fen has an explicit third-party trust/capability model.
Capability taxonomy #
The table above lists every method; this groups them by what they do and which tier sees them today. Capability category — not namespace — is the natural axis for any future public/privileged split: contribution and read-only introspection are safe for any extension, while mutation and infrastructure install are tier-sensitive.
| capability | methods | tier today |
|---|---|---|
| Contribute | register (9 public kinds), prompt, on/emit | base |
| Host control | actions.list, actions.invoke | privileged first-party extensions and test/harness API |
| Contribute (infrastructure) | register (:provider, :auth-backend, :session-backend, :presenter) | privileged (first-party) |
| Introspect (read-only) | list (14 kinds), introspect.collect, models.*, diagnostics.*, session.info/active-backend, auth.find-backend, settings.extension | base |
| Mutate | settings.set-defaults!, settings.set-thinking-default!, session.set-info!, session.append-state! | base |
| Drive | turn.submit!, enqueue, commands.dispatch | base |
| UI | ui.has-ui?/notify/prompt/select | base |
The surface is 15 namespaces and 27 leaf methods, with register fanning out to 13 contribution kinds and list to 15 extension-facing introspection kinds. Only the four infrastructure register kinds are tier-gated today; the Mutate methods are currently exposed to every extension regardless of source.
Registering typed actions #
Actions are a narrow typed host-control contract for harnesses and headless runtimes rather than model-facing tools. Every extension may register an owner-scoped action, and the loader supplies that owner from the extension manifest or entry name.
(api.register :action
{:name :stop
:description "Stop the active bounded goal run"
:parameters {:type :object :properties {}}
:invoke (fn [args ctx]
(stop-goal! api ctx))})
Action names are scoped by owner, so a privileged caller invokes :stop from the :goal extension as (api.actions.invoke :goal :stop {} ctx). api.actions.list returns frozen descriptors with :owner, :name, :description, and :parameters but never the invoke callback. api.actions.invoke validates arguments with fen.util.json_schema and returns either {:ok true :owner ... :action ... :state ...} or {:ok false :owner ... :action ... :error ... :state ... :details ...} when a domain rejection supplies its state snapshot. Unknown owners or action names and schema-invalid arguments are structured failures rather than thrown command text.
Actions are not tools, do not appear in tool descriptors, do not enter tool transcripts, and do not pass through model tool policy. Base (third-party) extensions may register actions but cannot list or invoke them. Privileged means the loader assigned source :first-party, so embedded first-party extensions receive api.actions.list and api.actions.invoke alongside the test/harness API. There are no in-tree production consumers yet; this seam exists for #175/#252-style headless harnesses. Issue #181 will formalize finer-grained capability tiers beyond this initial authority seam.
Keep one extension-owned domain transition behind every frontend adapter. For example, a command, model-facing tool, and privileged action may each adapt arguments and presentation around the same replace-items! or stop-goal! transition. The goal companion exposes stop, resume, and clear actions but defers start pending a typed fresh-objective/run-context contract.
Registering commands #
(api.register :command
{:name :hello
:description "Say hello"
:idle-only? false
:handler (fn [args ctx]
(api.emit {:type :assistant-text
:text (.. "hello " args)}))})
Command handlers receive:
args— text after the command namectx— the interactive run state used by built-ins
Commands with subcommands can use fen.util.subcommands to avoid ad-hoc argument parsing. The helper builds trim/lowercase dispatch, generated /cmd help, unknown-argument feedback, :usage, and a presenter-agnostic :subcommands descriptor that command completion can consume:
(local subcommands (require :fen.util.subcommands))
(let [sub (subcommands.build
{:name :mem
:emit api.emit
:summary "Memory diagnostics panel"
:default (fn [_args ctx] (toggle-panel ctx))
:subcommands
{:gc {:description "force a GC pass"
:handler (fn [_args ctx] (run-gc ctx))}
:off {:description "hide the panel"
:handler (fn [_args ctx] (hide-panel ctx))}}})]
(api.register :command
{:name :mem
:description "Memory diagnostics panel"
:usage sub.usage
:subcommands sub.descriptor
:handler sub.handler
:complete sub.complete}))
Subcommand handlers receive the trimmed argument string after the subcommand word plus the command context. A bare invocation calls :default when present, while /cmd help renders the generated table unless a real :help subcommand is declared. An unknown subcommand emits an error plus that help table; set :default-takes-args? true only for commands whose default accepts arbitrary free-form arguments.
Argument completion #
A command may add an optional :complete function to supply argument completions to the TUI's inline completion menu. Alternatively, a command may set :subcommands to a descriptor from fen.util.subcommands; the registry uses it as a completion fallback when no :complete hook is present. The menu filters as you type and opens above the input while editing a /command:
(api.register :command
{:name :skills
:handler (fn [args ctx] ...)
:complete (fn [arg-prefix ctx]
[{:label "redact-logs" :value "redact-logs"
:description "Redact sensitive strings"}])})
:complete receives the current argument word (arg-prefix) and the run context. It returns a list of choices shaped {:label str :value any :description str?} — the same shape api.ui.select consumes. :value is the token spliced into the input on commit. :label and :description are shown in the menu. The menu filters the returned choices by the typed word, so a completer may return its full candidate set. Errors in :complete are isolated and never crash input. Return [] or nothing for "no completions".
Commands that need to start a normal agent turn should use the small turn helper instead of mutating ctx.turn or ctx.busy? directly:
(let [result (api.turn.submit! ctx "Execute the approved plan now." {:when-busy :reject})]
(when (not result.ok)
(api.emit {:type :error :error result.error})))
api.turn.submit! accepts the command context, the user text to submit, and optional busy/display behavior. Calls through this API echo the submitted user text to the live transcript by default. Pass {:emit-user? false} to suppress that when an extension has already displayed equivalent text.
{:when-busy :reject}— return{:ok false :error "agent is busy"}when a turn is active.{:when-busy :steering}— queue the text as steering for the active turn and return{:ok true :queued true :queue :steering}.{:when-busy :follow-up}— queue the text as a follow-up for the active turn and return{:ok true :queued true :queue :follow-up}.
When idle, it starts the same normal user-turn path used by presenter input and returns {:ok true :started true}. Empty text returns {:ok false :error "cannot submit an empty user turn"}. Unknown :when-busy modes return {:ok false :error "invalid when-busy mode: ..."}.
Enqueueing steering and follow-ups #
Use api.enqueue when an extension needs the same steering or follow-up queue that interactive user input uses. This is an interactive-session-only API: without a live interactive runtime it returns {:ok false :error "no interactive runtime"}. The method accepts exactly :steering or :follow-up, a non-empty text string, and optional options; invalid text returns a structured {:ok false :error ...} result. Queued messages preserve FIFO ordering with user-entered messages, update presenter queue counters, and are cleared by /cancel-all or /queue clear like user-entered entries.
(api.enqueue :steering "Use the new test result before continuing.")
(api.enqueue :follow-up "Review the completed background task.")
Follow-ups normally wait for the ordinary agent queue callback, including when queued while idle. Pass {:start-if-idle? true} for a follow-up from an idle event handler when it should start a normal turn at the next safe runtime tick. The option never starts a turn reentrantly and has no effect while a turn is active. The API does not impose caps or rate limits; extensions own that policy. In particular, a turn-completion handler that enqueues {:start-if-idle? true} on every completion creates an unbounded loop, and /cancel-all cannot durably stop it while that handler remains active; callers must supply caps and termination policy.
Registering tools #
Tool specs match the built-in AgentTool shape handled by packages/core/src/fen/core/tools.fnl. Tools may set :exposure :search to remain discoverable through tool_search; their schemas become provider-visible after activation while execution remains registered throughout the conversation. Omitting :exposure preserves the existing immediately visible behavior:
(api.register :tool
{:name :greet
:label "greet"
:description "Greet someone"
:parameters {:type :object
:properties {:name {:type :string}}
:required [:name]}
:execute (fn [args _ctx]
{:content [{:type :text
:text (.. "hello " args.name)}]})})
Built-in background subagents #
The built-in subagent tool remains blocking by default. Set background: true explicitly to launch a detached child and return immediately with its run ID. Detached children are pumped cooperatively from presenter ticks, and their progress appears in the existing /subagents and status surfaces. Background mode currently requires the TUI presenter; one-shot and blocking-input presenters reject it because they cannot provide idle ticks. Reloading the subagent extension cancels and reaps active background children.
A completed background job queues a compact follow-up for the parent agent but does not start a turn automatically. Use /subagents show RUN_ID to inspect its stored result and diagnostics. Pass collect: "full" to queue the complete final text instead of the default summary. Use /subagents steer RUN_ID NOTE to steer a running child with additional context, and /subagents cancel RUN_ID to stop it.
At most four blocking or background subagent runs may be active together. Launches at the limit are rejected rather than placed in another queue. Background jobs are read-only; the parent agent owns repository edits. Normal quit, cancellation, and timeout clean up child process groups. Containment is process-group scoped: a descendant that escapes the group (for example by calling setsid()) can survive a timeout or cancellation, so whole-tree termination is not guaranteed without the optional sandbox. Cleanup after an uncatchable parent crash or SIGKILL is best-effort and is not guaranteed.
Background run records and process handles survive /reload, while public introspection exposes only sanitized run data.
Hooks #
Extensions may register a pre-tool-call policy hook that runs before tool argument validation and execution for both built-in and extension tools.
(api.register :hook
{:before-tool
(fn [ctx]
(when (and (= ctx.name :bash)
(string.find (or ctx.arguments.cmd "") "rm %-rf"))
{:block true :reason "dangerous command"}))})
The hook receives {:name :arguments :tool :cwd :source} and returns nil or {:allow true} to allow a call, or {:block true :reason "..."} to block it. ctx.arguments is a shallow copy of the submitted arguments, and policy hooks cannot rewrite the arguments used for execution; treat the table and any nested values as read-only. Hooks run in registration order, the first block wins, and later hooks are not run after a block. A throwing before-tool hook fails closed, blocks the call with a policy hook failed reason, and is recorded in extension diagnostics. This is an intentional behavior change from the old fail-open pcall; purely observational consumers should use the events bus instead of a policy hook. Blocked calls are normal structured tool errors visible to the model and user. Confirmation is intentionally not part of this Phase 1 API and is planned for a future Phase 2.
System prompt fragments #
Prefer the ergonomic api.prompt helper:
(api.prompt
"Extra instruction from my extension."
{:id :my-extra-instruction
:title "My extra instruction"
:description "Short inspection text"
:order 90})
Fragments are sorted by numeric :order and registration sequence, then joined with blank lines. The first argument to api.prompt may also be a function evaluated with the prompt render context when the system prompt is built. Failures degrade to an HTML comment instead of crashing prompt assembly.
To inspect prompt size without dumping the prompt text, run /prompt stats in the TUI. It re-renders each registered fragment against the live session opts/tools and reports per-fragment byte length and an approximate token count, plus a total, so you can see which fragments dominate the system prompt. Because it renders fresh, volatile fragments (date, cwd) reflect the current moment rather than the value baked into the prompt at agent construction.
Extension logs #
Call api.log for owner-tagged operational diagnostics that must survive stderr scrollback and extension reloads. Each call enters the shared bounded in-memory ring (500 records), appends a sanitized JSON record to ~/.local/state/fen/logs.jsonl, and mirrors through fen.util.log when the configured FEN_LOG level allows it. Records have named owner, level, timestamp, msg, and optional session fields, and table payloads are JSON-rendered with secret-like fields redacted. Use api.list :logs or (:get :extensions :logs) through agent_state for structured inspection by owner or level. The JSONL file does not rotate in v1, so operators should monitor its disk usage.
(api.log :info "wrote 12 messages to session.jsonl")
(api.log :debug {:retry-count 3 :reason :rate-limit})
Event bus #
Subscribe to one event:
(api.on :tool-call
(fn [ev]
(api.emit {:type :info
:text (.. "tool: " (tostring ev.name))})))
Subscribe to all events:
(api.on :* (fn [ev] ...))
Handlers are pcall isolated so one failing extension does not stop sibling handlers.
Common event types include:
:agent-started,:agent-turn-complete,:agent-shutdown:runtime-tickfor small cooperative extension-owned work; handlers must not wait for external progress:llm-start,:llm-end:tool-call,:tool-result:assistant-text,:assistant-thinking:assistant-text-delta,:assistant-thinking-delta,:assistant-stream-end:hosted-tool, a server-executed tool such as hosted web search starting or finishing; it is never a:tool-calland creates no content block.:user,:info,:queued,:error,:cancelled:extension-loaded- presenter-control events such as
:reset-conversation,:redraw,:set-status-info :agent-turn-completefires once after a submitted user turn is fully done, including tool loops, queued follow-up/steering consumed by that step, cancellation cleanup, and the presenter returning to idle. Its:statusis:ok,:cancelled, or:error, and:stateis the run-state that ran the turn.:dismiss— emitted by the TUI onEsc; extensions owning a togglable panel should subscribe and close it (no-op when not displayed):hint—{:type :hint :text str :key str}, a short dismissable suggestion the TUI shows as its empty-input placeholder, never as a transcript row. It is dropped if the user is already typing, and a:keyis shown at most once per process. Name the command to run in:text, e.g.topic changed · /handoff.
Custom event types should be namespaced by convention, e.g. :git-checkpoint/snapshot-created.
Registering introspection snapshots #
An :introspect contribution exposes a sanitized, read-only snapshot of extension-owned state. Snapshots are collected on demand by agent_state, /extensions <name>, and future runtime diagnostics.
(api.register :introspect
{:name :panel
:description "Current panel state"
:snapshot (fn [_ctx]
{:visible? state.visible?
:selected-name state.selected-name})})
Names are owner-scoped: two extensions may both register :summary. Use api.list :introspectors to inspect descriptors without executing snapshot thunks. Use api.introspect.collect to collect owner-scoped outputs:
(api.introspect.collect) ; all owners
(api.introspect.collect :my-ext) ; one owner
Snapshots must be cheap, side-effect-free, non-blocking, and must not expose secrets. Return plain JSON-friendly data: strings, numbers, booleans, nil, arrays, and tables. Avoid functions, userdata, threads, and cycles. Snapshot failures are isolated and surface as {:error "..."} without preventing other snapshots from being collected. For hot reload, snapshot thunks should resolve behavior and persistent state at call time rather than capturing stale function locals.
Presenters #
A presenter is an interactive host such as the built-in TUI, a future REPL, or an RPC server. Only one active presenter owns the UI slot.
(api.register :presenter
{:name :my-presenter
:active? true
:init (fn [ctx] ...)
:run (fn [ctx] ...)
:shutdown (fn [ctx] ...)
:ui {:notify (fn [text opts] ...)
:prompt (fn [opts] ...)
:select (fn [opts] ...)}})
main.fnl calls presenter lifecycle through core.extensions:
init-active-presenter → run-active-presenter → shutdown-active-presenter
The built-in TUI is a first-party extension under extensions/adapters/presenters/tui/. For how its regions, status items, panels, and controls fit together, see the TUI design guide.
api.ui public surface #
api.ui is the only extension-facing UI namespace; it is not going away or flattening to top-level api.select/api.notify helpers. Its intended public surface is exactly four methods:
api.ui.has-ui?()— true when a presenter is active and owns the UI slot.api.ui.notify(text, opts)— show a message through the active presenter.api.ui.prompt(opts)— ask the active presenter for free-text input.api.ui.select(opts)— ask the active presenter to pick fromopts.choices;opts.initial-queryoptionally supplies the selector's initial search text.
Selector choices have the shape {:label str :value any :description str?}. Search-capable presenters initialize their filtering and ranking from opts.initial-query, while presenters without interactive filtering may ignore it.
Everything inside fen.core.extensions.register.presenter beyond build-ui-slot (slot promotion, presenter registration/lookup, lifecycle dispatch) is core runtime plumbing, not part of this contract, and may change without notice.
UI helper fallback behavior #
api.ui methods dispatch to the active presenter's :ui table when one is registered. When no presenter is active, each method falls back to explicit, non-blocking behavior instead of touching stdin:
notifywrites the message to stderr as a plain line.promptandselectlog a one-line warning to stderr and returnnil.
Extensions that need interactive input should call api.ui.has-ui?() first and treat a nil result from prompt/select as "no UI available" rather than "the user answered nothing."
Registering status items #
A :status is one block in the presenter's status bar, in the Waybar/Polybar style: each item renders independently and the presenter composes them. First-party status items in the TUI surface the model name, context tokens, queue counts, and scroll offset.
(api.register :status
{:name :git-branch
:side :right
:order 10
:render (fn [_ctx]
{:text (current-branch) :style :dim})})
| field | meaning |
|---|---|
:name | Identifier (required). Owner-scoped; two extensions may share a name. |
:render | (fn [ctx] {:text str :style style-keyword}) (required). Return nil to hide for this frame. |
:side | :left or :right. Defaults to :left. |
:order | Number; lower = closer to the side anchor. Defaults to 50. |
Sort is ascending by :order, then owner, then name. The right group is right-aligned; the left group is left-aligned. :render is invoked every frame and must be cheap and side-effect-free.
Registering panels #
A :panel is a bounded vertical region above the input or below the status bar. It owns a row count per frame and a list of rows to paint; the presenter handles geometry, clipping, and clamping to available space. The TUI's busy spinner is the smallest first-party example (extensions/adapters/presenters/tui/panels/busy.fnl).
(api.register :panel
{:name :recent-commits
:placement :above-input
:order 20
:height (fn [_ctx] 3)
:render (fn [_ctx]
[{:text (.. "HEAD: " (head-summary)) :style :dim}
{:text (.. "+ " (changed-files)) :style :dim}
{:text "" :style :dim}])})
| field | meaning |
|---|---|
:name | Identifier (required). |
:height | (fn [ctx] <int>) (required). Non-negative; 0 hides the panel for this frame. |
:render | (fn [ctx] [<row>...]) (required). Each row is {:text str ?:style style-keyword ?:segments [...]}. An empty list is equivalent to height 0. |
:placement | :below-status or :above-input. Defaults to :above-input. |
:order | Number; lower = closer to the placement anchor. Defaults to 50. |
:below-status panels stack downward from the status row; lower :order is closer to the top. :above-input panels stack upward from the input row; lower :order is closer to the input. The presenter walks panels per-frame inside pcall, so one extension's render error becomes a single panel-error:<name> row instead of crashing the frame.
:render and :height run on every frame. Keep both cheap; do expensive work in cooperative event handlers and cache the result in extension state.
Semantic styles #
:status items and :panel rows declare colors with semantic keywords; presenters own the actual color tables.
| keyword | typical use |
|---|---|
:user | User-authored content / accent |
:assistant | Assistant content / section headings |
:tool | Tool-call labels |
:error | Errors |
:dim | Secondary or muted text |
:status | Default for status items (TUI: reverse-video bar fg) |
Unknown styles fall through to a presenter default; new styles will be added as the theme system matures, so :render should not depend on the exact rendering of any one keyword.
Context compaction #
The first-party compact extension (extensions/behaviors/companions/compact/) exposes both /compact [guidance] and an agent-callable compact tool. Both paths summarize older messages, keep recent messages verbatim, append a durable :compaction session entry, and replace the active model context only after persistence succeeds. The tool accepts optional guidance describing facts, files, progress, or next steps that its summary must preserve. It should be called only when substantial older context can be discarded, not repeatedly on short sessions. Agent-triggered compactions are recorded with :trigger :agent; manual commands retain :trigger :manual; auto-compactions use :trigger :auto.
Auto-compaction #
Auto-compaction is off by default. Enable it with a positive autoCompactTokens under extensions.compact in settings.json, e.g. "extensions": {"compact": {"autoCompactTokens": 150000}}. Absent or non-positive, fen only compacts on /compact or the tool. The threshold is compared against the same approximate context-token estimate that /compact reports as tokens-before; fen does not derive it from the model's context window.
Each completed turn is evaluated once, on the next runtime tick, and only while the runtime is idle. A turn that starts right away, such as a goal iteration or a queued follow-up, skips that evaluation, as does a cancelled turn. Presenters without runtime ticks (--print, json) never auto-compact. Evaluation skips silently when the session backend cannot persist compactions or there is nothing to compact.
At or above the threshold, fen compacts. Only the experimental decide extension, when enabled, can compact earlier, from 80% of the threshold.
Auto-compactions run like /compact, can be cancelled the same way, and are recorded with :trigger :auto. A failed auto-compaction reports one error and is not retried until another turn completes.
Goal companion #
The first-party goal extension (extensions/behaviors/companions/goal/) adds /goal for bounded autonomous objective execution. It is the interactive MVP for the goal-orchestration roadmap: a user gives fen an objective, fen works through normal model turns, and the extension stops the run when the model reports completion/blockage/error or when the iteration cap is reached. The MVP composes existing primitives instead of adding a second agent loop. The submitted goal prompt tells the model to draft or reuse a short plan, use subagent for self-contained scout/plan/review work when helpful, run appropriate checks, and end every iteration with an explicit GOAL_STATUS: ... marker.
Subcommands #
| command | effect | |
|---|---|---|
/goal <objective> | Start a bounded goal run with the default iteration cap. | |
/goal --max-iterations N <objective> | Start with an explicit cap. | |
/goal status | Print the current goal state. | |
/goal stop | Stop future autonomous iterations. | |
/goal resume | Explicitly resume a blocked, stopped, or errored goal if it is still under its cap. | |
| `/goal panel on\ | off` | Show or hide the goal panel; bare /goal panel toggles it. |
/goal clear | Clear the in-memory goal state. |
The visible default cap is 10 iterations, and explicit caps cannot exceed 100, so a goal run has substantially more CLI headroom without becoming unbounded by default. /goal resume rejects completed, cap-reached, idle, and already-running goals rather than guessing that another iteration is wanted. /goal stop marks and persists the run as stopped before requesting cooperative cancellation of an active goal turn, so its completion cannot schedule another autonomous iteration or revive after restart. Goal continuations are submitted directly instead of entering the shared follow-up queue, so stopping a goal leaves user-owned steering and follow-up entries untouched.
Iteration lifecycle #
A goal run starts by submitting a hidden user turn through api.turn.submit!. The extension records running, iteration-count, max-iterations, the objective, and the latest model result in its non-reloadable state module (extensions/behaviors/companions/goal/state.fnl). Each authoritative transition is also appended to the current JSONL session through the generic extension-state API. On startup with --continue or after /resume, the latest valid goal entry for that exact session is restored. A persisted running entry is treated as an interrupted iteration: fen reports it as blocked and resumable, and waits for an explicit /goal resume rather than autonomously submitting work. /new switches to an empty session and clears the in-process goal view, while /reload keeps the non-reloadable state table and does not replace it with state from another session. When :agent-turn-complete fires, the extension reads the final GOAL_STATUS marker:
| marker | effect |
|---|---|
GOAL_STATUS: continue | Start the next iteration if the cap has not been reached. |
GOAL_STATUS: done | Mark the goal done. |
GOAL_STATUS: blocked | Mark the goal blocked and leave recovery to the user. |
GOAL_STATUS: error | Mark the goal errored. |
If the marker is missing, the run is marked blocked instead of guessing whether to continue. If the marker asks to continue at the cap, the run is marked cap-reached and no further turn is submitted. A user stop or cancelled turn is stopped, model completion is done, a recoverable wait is blocked, an unexpected failure is error, and exhausted autonomy is cap-reached. Each start, manual resume, automatic continuation, and stop decision emits a visible :info event with structured :source :goal, :decision, :status, :reason, :iteration, and :max-iterations fields for audit-oriented subscribers.
Status, panel, and context budget #
While a goal has visible state, the extension contributes a left-side status item such as goal:1/3 or goal:done. The optional panel shows the objective, iteration count, last reason, and a short preview of the latest result. The extension also exposes an introspection snapshot for /extensions goal and agent_state.
Before scheduling a follow-up iteration, /goal checks the same rough context-token estimator used by the status line. When the conservative high-context threshold is reached and the compact tool is available, the next iteration is required to call compact before doing more work. The goal blocks rather than continuing blindly if the tool is unavailable, the call fails, or the iteration finishes without a successful agent-triggered :compaction-summary event. Provider context-limit failures are reported as blocked with /compact and /goal resume recovery guidance instead of being collapsed into generic runtime errors. Autonomous continuation does not change tool policy: destructive or external actions still use the normal policy and confirmation surfaces.
Plan companion #
The first-party plan extension (extensions/behaviors/companions/plan/) adds /plan for drafting, revising, inspecting, and approving execution plans. While a plan or revision turn is running, its before-tool hook keeps the model in a read-only lane by allowing only read, grep, find, ls, agent_state, and fen_docs. Mutating tools such as bash, edit, and write are blocked until the user approves the captured plan. See tools.md for what each of these tools does.
Subcommands #
/plan dispatches on its first argument; anything it does not recognize as a subcommand is treated as a new planning request.
| command | effect | |
|---|---|---|
/plan | Print usage. | |
/plan <request> | Draft a read-only plan for <request>, entering plan mode. | |
/plan revise <notes> | Revise the captured plan using <notes> as guidance, re-entering plan mode. | |
/plan approve | Execute the captured plan as a normal user turn. | |
/plan show | Print the captured plan (or a hint if none exists). | |
/plan cancel | Leave plan mode and clear the captured plan. | |
| `/plan panel on\ | off` | Show or hide the plan panel; bare /plan panel toggles it. |
revise and approve require a captured plan; without one they emit an error and do nothing. Subcommand names are matched case-insensitively.
Mode lifecycle #
The extension tracks a small mode machine in its non-reloadable state module (extensions/behaviors/companions/plan/state.fnl):
idle ──/plan <request>──► planning ──turn ok──► ready
▲ │
│ ├──/plan approve──► idle
/plan revise │
│ │
revising ◄──────────────┘
│
turn ok──► ready
planning and revising are the two active read-only states (planning? is true in both), so the before-tool allowlist applies during a revision turn as well. When a plan or revision turn completes successfully, on-turn-complete captures the result as last-plan and moves to ready. ready means a plan has been captured and the model is no longer in the read-only lane; the extension is idle and waiting for the user to approve, revise, or cancel. approve flips the mode back to idle before submitting, so the execution turn runs with normal tools available. cancel, a cancelled turn, a failed turn, or an :error/:reset-conversation event all return the mode to idle.
Status and panel #
While a plan is active (any mode other than idle), the extension contributes UI state:
- A left-side
:statusitem renderingplan:<mode>(for exampleplan:planningorplan:ready). - An above-input
:panel(:order 34) showing the current mode, the captured goal, a preview of the captured plan (truncated to a few lines), and the last tool blocked by the read-only hook.
The panel only renders when it is both active and visible; /plan panel toggles the visible flag, and /plan panel on|off sets it explicitly.
Approving a plan #
/plan approve submits the approved plan through api.turn.submit! as a normal user turn:
Approved plan:
<plan text>
Execute this plan now.
That keeps turn/coroutine ownership inside the runtime instead of having the extension mutate presenter state directly.
> The read-only allowlist (READ_ONLY_TOOLS at the top of init.fnl) is hand-maintained. > Tools carry no read-only/mutating metadata to derive it from, so the list is an explicit allowlist that fails safe: any tool not named there — including newly added inspection tools — is blocked while planning. > Keep it in sync as new read-only tools land, or they will be unusable in plan mode.
Subagents #
The first-party subagent extension (extensions/behaviors/companions/subagent/) registers a subagent tool that delegates a focused task to a live child fen process with its own context window, a dedicated system prompt, and explicit provider/model routing. The child's persona comes from either a discovered named agent file or an inline prompt argument, so an agent file is convenient but not required (see "Tool" below). By default the child inherits the parent agent's provider and model when the subagent tool context exposes them. For agentic launches, first call the tool with action: "models"; this refreshes catalogs, filters out credential-gated providers that are not authenticated, omits fallback IDs when a dynamic catalog cannot be verified, and returns exact provider/model routing pairs. The caller should match one pair, pass both fields explicitly, and provide a self-contained task with cwd, expected artifact, and short bounds. After failure, inspect or steer the retained run instead of repeating it unchanged, and verify child evidence before reporting completion. Explicit per-call routing overrides named-agent frontmatter; frontmatter can otherwise override model, provider, or both. A model-only override keeps the inherited provider and uses the frontmatter model. A provider+model override uses both frontmatter values. A provider-only override passes the frontmatter provider and intentionally omits the inherited model, so the child resolves that provider's default model through normal CLI startup. With a provider, the model value is that provider's model id, and the child receives it as the canonical provider/model so ids containing a slash (OpenRouter's openrouter/auto) round-trip unchanged. The child normally returns its final text, so long or self-contained work (research, a scoped edit, a review pass) stays out of the parent's context. If the child fails, times out, is cancelled, exits without an exit event, or answers with stop-reason = :error, the tool returns visible diagnostic text instead of an empty result. A successful child with empty final text returns a non-error diagnostic summary so callers can distinguish "no final text" from a failed child.
The live child #
Each run spawns exactly one child, fen --presenter rpc --system-file PATH --no-session plus its routing and tool-policy flags, and drives it over the wire protocol. The task, prefixed with the requested, effective, and physical cwd, is the child's first prompt. The parent keeps one conversation per run and maps every mid-run change to a control message; nothing restarts:
- A steering note (
/subagents steer, thesteeraction, or a TUI subagent tab) becomessteer, injected at the child's next turn boundary. - An investigation budget (
max-turns,max-tool-calls, or anartifact-checkpoint-secondscheckpoint with no artifact) reached while a turn runs becomes onefinalizewith a note asking for findings now; the child answers from its whole conversation with tool execution disabled.max-turnstrips only when the child continues past its last allowed turn, so an answer on exactly that turn is never interrupted. - Cancellation (
/subagents cancel, thecancelactions, turn cancellation of a blocking run,/new,/reload, and shutdown) becomescancel. - When the child is back in
readywith every control acknowledged, its task turn is done, and the parent sendscloseto getresultandexit.
Run status comes only from the child's exit event and the process exit: done with a result whose last assistant stopped normally completes the run (an error, tool-use, or aborted stop fails it), cancelled, failed, and timed-out end it with that status, and a child that exits without an exit event failed. Without a result, the tool reports the latest assistant text the child streamed. cancel and finalize land only at the child's next cooperative yield, so the parent keeps a backstop: a child that has not exited a few seconds after cancel or its own exit is killed, and a finalize with no exit within two minutes is cancelled. The kill reaches the child's process group but not tool subprocesses that started their own sessions, so the parent always tries cancel first. The child also gets the run deadline as FEN_WIRE_DEADLINE, and the process timeout trails it by five seconds.
Children stay --no-session: fen has no per-process session location, and a child session in the user's store would clutter /resume and race --continue. The run's retained event tail and /subagents show are the inspectable record. Blocking and background runs share one driver; a blocking call pumps its child until the run settles while yielding to the parent turn. Because the child is a spawned fen, changes to the spawn(argv, env) path in the fen_process C binding need a full nix build .#fen / make dev-nix rather than a bare /reload.
Run status and cancellation #
The subagent extension tracks active and recent child runs. A status-line item appears while child runs are active, for example subagent:1 running. Use /subagents to list active and recent runs with run id, agent, parent-facing status (running, done, timed-out, failed, cancelled, or budget-limited), elapsed time, and turn/tool-call counts. Use /subagents show RUN_ID to render a running job's live retained activity tail or a finished run's retained transcript, final result, and separate process-output tail. The parent appends the child's forwarded display events to the run, retaining the latest 50 events per run across at most 20 runs. Renderable progress keeps canonical presenter event shapes, so subagent workspaces reuse the same streaming, Markdown, thinking, tool pairing, truncation preview, error, scrolling, and selection pipeline as the main transcript. If a workspace falls behind retention, it displays an explicit omitted-events row before replaying the retained canonical tail. Malformed or invalid child lines are counted as event errors and skipped; a wire version mismatch fails the run. When a child times out or fails, its tool result includes a compact tail of the latest child tool/text/error events and records whether partial assistant text was observed. Each run also records time-to-first-artifact when the child first produces a final answer, an edit/write mutation, a failing tool result, or a bash result containing a diff. Pure discovery events remain visible in the progress tail but do not count as artifacts. Launches may set artifact-checkpoint-seconds; active runs that exceed it with no artifact show none! in /subagents and time-to-first-artifact-ms: none yet in /subagents show, and the budget is enforced with finalize as described above. Run details expose the turn/tool counters, the configured budgets, whether budget finalization fired and why, and any repeated-inspection warnings. Repeated read/grep/find/ls/bash inspection fingerprints are tracked from the event stream so timeouts after repeatedly inspecting the same file or query are visible. Before a launch, retained history is also checked for the same normalized agent/task/cwd/provider/model fingerprint: the third and later timeout with no artifact/mutation warns the caller and /subagents to steer a retained run or change the plan instead of starting another identical child. The warning count is bounded by the 20 retained runs and says when eviction has truncated its history. A steer the child rejects (for example after the run is closing or finalizing) is recorded as a steering-rejected event. Use /subagents cancel RUN_ID to cancel one active run, or /subagents cancel to cancel every active run and request cancellation of the current turn. The same lifecycle operations are available agentically through the subagent tool's management actions. models refreshes configured provider catalogs and lists exact authenticated provider/model pairs for a subsequent launch. list and show inspect runs, wait cooperatively awaits completion, steer redirects active work, cancel and cancel-all stop children, retry relaunches a retained background run, and remove deletes one inactive record. clear removes all inactive history, while the explicitly destructive reset cancels detached work and clears history. Management results include structured run data in details, so the main agent does not need to parse their human-readable text. Blocking remains the launch default; background: true returns immediately with a run id and the TUI pumps the child on runtime ticks. /new cancels and synchronously reaps detached children, clears old run history, and removes their workspaces so a fresh conversation cannot inherit stale jobs or tabs. Background records whose job disappears are finalized as failed rather than remaining running forever. Completed run details expose time-to-first-artifact-ms, first-artifact-kind, and first-artifact-summary when an artifact was observed.
Token usage telemetry #
Each run accumulates provider-reported token usage as it arrives. Per-turn :llm-end usage is folded into durable run totals as events drain, so completed-turn usage survives event-retention truncation and runs that end without a result. The child's result usage sums its whole conversation and replaces the event-derived total rather than being added to it, so usage is never double counted.
Run details expose usage (input, output, cache-read, cache-write, reasoning, total-tokens), usage-turns, usage-provenance (per-field provider-reported versus estimated; a total derived from input+output is flagged estimated), usage-source (final-result or events), and usage-complete?. Event-only totals are marked incomplete because an in-flight final turn may be unaccounted.
Inspect usage from the TUI or the tool:
(subagent {:action "usage"}) ; per-run table plus workflow totals
(subagent {:action "usage" :run-id "subagent-3"})
/subagents usage [RUN_ID] renders the same view, with a TOTAL row and a by-provider/model/outcome summary. The state introspector and action=usage structured details expose the same fields without scraping rendered text. Context estimates such as approx-context remain transient status information and are not a substitute for cumulative provider usage.
Agent discovery #
Agents are markdown-with-frontmatter files, discovered like skills. Roots, in precedence order (project beats user beats bundled):
./.fen/agents/*.md— project${XDG_CONFIG_HOME:-~/.config}/fen/agents/*.md— user- bundled default definitions — currently
scout,reviewer, andplanner
Use /agents to list the discovered agents, their project/user scope, explicit provider/model overrides, effective timeout, and description. The subagent extension also adds a compact prompt fragment when agents exist and the subagent tool is available to the model. It advertises only stable launch names and descriptions so the model can choose names for the subagent tool without receiving local paths or override details. The fragment is capped; run /agents for the full discovered list.
An agent is launched by the .md filename (without extension). The frontmatter name: is descriptive metadata and should normally match the filename. Format:
---
name: scout
description: Fast read-only recon
model: claude-haiku-4-5
provider: anthropic
timeout-seconds: 2700
max-turns: 4
max-tool-calls: 10
tools: read, grep, find, ls
---
You are a scout. Briefly answer the question and stop.
name and description are required; model, provider, and timeout-seconds are optional. Frontmatter values run to the end of the line, so don't add inline # comments — they become part of the value. Omitting both model and provider makes the child inherit the parent provider/model when available. Setting only model inherits the parent provider and uses that frontmatter model. Setting both provider and model pins both. Setting only provider passes that provider and intentionally omits the parent model, so the child uses normal CLI default-model resolution for that provider. timeout-seconds defaults to 2700 (45 minutes). max-turns and max-tool-calls are optional launch budgets for bounded workflows such as reviews; reaching one makes the child finalize rather than silently looping through more inspection. tools is an optional comma- or whitespace-separated hard allowlist passed to the child as --tools; omit it to leave the child's full tool set available. Parent CLI restrictions always apply to named and inline children: parent --tools intersects this allowlist, parent --denied-tools removes denied names or is forwarded when no child allowlist exists, and parent --no-tools forces --no-tools. A child can be narrower than its parent but can never gain a tool the parent lacks, and an empty intersection rejects the launch before a child process is spawned.
The body becomes the child's system prompt (delivered with the --system-file CLI flag). models.json custom providers work automatically because the child reads the same config directory. Ready-to-use default agents scout, reviewer, and planner are bundled with fen. Copy-pasteable source copies live in extensions/behaviors/companions/subagent/examples/. Drop one into .fen/agents/ or the user agents directory only when you want to customize or override the bundled definition.
Tool #
The subagent tool either launches a child or manages existing runs. A launch says who the child is (a named agent or inline prompt) and what it should do (task), plus optional routing and cwd controls. A management call supplies action and, for per-run actions, run-id. Subagent tool argument names use the hyphenated spellings in this table.
Parameters:
| Parameter | Required | Purpose |
|---|---|---|
action | management only | models refreshes and lists authenticated model routing pairs; review-worktrees creates detached sibling review worktrees; cleanup-review-worktrees safely removes unchanged workflow-created review worktrees; usage reports per-run and workflow token usage; list, show, wait, steer, cancel, cancel-all, remove, retry, clear, and reset manage runs. |
run-id | per-run actions | Stable run id required by show, wait, steer, cancel, remove, and retry. |
note | steer | Steering note delivered to the live child at its next turn boundary. |
task | launch only | The work handed to the child, delivered as its first user message. What to do. |
agent | one of agent/prompt | Name of a discovered agent definition (the .md filename without extension). Who the child is. |
prompt | one of agent/prompt | Inline system prompt used directly as the child's persona, so no agent file is needed. Who the child is. |
cwd | optional | Working directory for the child or review-worktrees source; validated to exist. Defaults to the parent's cwd. |
ref | review-worktrees only | Git revision to check out detached, defaulting to HEAD. |
worktree-count | review-worktrees only | Number of detached sibling review worktrees to create, from one through four. |
model | optional | Override the child model. Defaults to agent frontmatter, else the inherited parent model. |
provider | optional | Override the child provider. A provider-only override omits the inherited model. |
timeout-seconds | optional | For launches, shorten the child budget within policy; for wait, set the polling budget (default 30 seconds). |
max-turns | optional | Launch-time budget for completed child LLM turns; reaching it before a final answer sends finalize, so the child returns findings now without tools. |
max-tool-calls | optional | Launch-time budget for child tool calls; reaching it before a final answer sends finalize, so the child returns findings now without tools. |
artifact-checkpoint-seconds | optional | Launch-time no-progress budget; when no artifact appears within it the parent sends finalize, like max-turns/max-tool-calls, and records the no-artifact-yet state. |
background | optional | Return immediately with a run id and pump the detached child from TUI runtime ticks. |
collect | optional | Queue a compact summary (default) or full result when a background run completes. |
task names the job; agent/prompt name the persona — keep them distinct. When both agent and prompt are supplied, the named agent wins.
Named agent:
(subagent {:agent "scout"
:task "what files define the provider interface?"
:cwd "."}) ; cwd optional; validated to exist
Inline prompt — runs without any agent file, using the prompt as the child's system prompt directly:
(subagent {:prompt "You are a one-off reviewer. Answer briefly and stop."
:task "summarize the risk in the current diff"
:model "claude-haiku-4-5" ; optional inline routing
:provider "anthropic" ; optional inline routing
:timeout-seconds 120}) ; optional per-call budget
Per-call timeout-seconds works for named and inline agents. It may shorten a run but cannot raise the agent's configured timeout or the 2700-second (45-minute) default ceiling. Set artifact-checkpoint-seconds when the parent needs an early no-progress signal before the full timeout. For review delegations, prefer short explicit budgets such as timeout-seconds: 300, max-turns: 4, and max-tool-calls: 10; longer timeouts should be an explicit operator choice, not the default response to a no-artifact review loop. Inline model and provider follow the same routing policy as equivalent agent frontmatter, so a provider-only inline override also omits the inherited model. Prefer a named agent when you want reviewable, reusable policy; use an inline prompt for a quick one-off delegation that isn't worth a file.
For parallel review, create detached siblings first, then launch ordinary reviewer or scout calls concurrently with the returned details.worktrees[*].path as each child cwd. The parent remains the only process that applies edits. Each child receives cwd context automatically, but its task should still begin with pwd, git status --short, and the expected diff/ref preflight.
(subagent {:action "review-worktrees" :ref "main" :worktree-count 2})
;; Launch up to two ordinary read-only reviewer/scout calls in parallel using
;; the returned paths, then inspect their evidence and apply any fixes here.
(subagent {:action "cleanup-review-worktrees"}) ; removes only unchanged paths created above
The helper never checks out or removes the caller's worktree. Cleanup refuses any path not recorded as workflow-created and refuses a review worktree with changed status or HEAD.
Agentic management examples:
(subagent {:action "list"})
(subagent {:action "show" :run-id "subagent-2"})
(subagent {:action "wait" :run-id "subagent-2" :timeout-seconds 30})
(subagent {:action "steer" :run-id "subagent-2" :note "focus on tests"})
(subagent {:action "cancel" :run-id "subagent-2"})
(subagent {:action "retry" :run-id "subagent-2"})
(subagent {:action "remove" :run-id "subagent-2"})
(subagent {:action "cancel-all"})
(subagent {:action "clear"})
(subagent {:action "reset"})
clear only removes inactive history and rejects while runs are active. reset is the explicit destructive convenience operation: it cancels and reaps detached children before clearing history. A blocking child belongs to its parent turn, so reset requests turn cancellation and reports that it must be retried after the blocking child exits.
Simplify companion #
The first-party simplify extension (extensions/behaviors/companions/simplify/) adds /simplify for a quality-only cleanup pass over changed code: reuse, simplification, efficiency, and altitude. It does not hunt for or fix bugs and does not change behavior; use a dedicated bug-focused review for correctness.
It is composition over existing primitives. The command computes the changed-file set (shelling git via process.run-captured) and submits a structured turn with api.turn.submit!. The submitted prompt drives the main agent to fan out one read-only subagent reviewer per changed file (using the bundled simplifier agent), consolidate the findings, discard anything risky or behavior-changing, then apply the safe simplifications and print a summary. Subagents only review; the parent applies the edits so they stay coherent under one coordinator.
Subcommands #
| command | effect |
|---|---|
/simplify | Review and apply quality cleanups on the current working-tree changes. |
/simplify <ref> | Simplify changes since <ref> (for example /simplify main). |
/simplify show | Reprint the last simplify summary. |
/simplify help | Print usage. |
While a simplify turn runs, a left-side simplify:running status item is shown, and an :introspect snapshot exposes the status and last summary for /extensions simplify. When git is unavailable or the directory is not a repo, the command still runs and asks the model to discover the diff itself; when there are no changes it exits early without starting a turn.
The simplifier agent #
/simplify delegates per-file review to a simplifier agent. A ready-to-use definition ships at extensions/behaviors/companions/simplify/examples/simplifier.md; copy it into .fen/agents/ (project) or ${XDG_CONFIG_HOME:-~/.config}/fen/agents/ (user) to enable isolated review. If no simplifier agent is found the command prints a one-line hint and proceeds with inline review instead, so it still works without the drop-in.
Skills companion #
The first-party skills extension (extensions/behaviors/companions/skills/) discovers and exposes Agent Skills.
Discovery #
SKILL.md files are discovered recursively from these roots, in priority order:
${XDG_CONFIG_HOME:-~/.config}/fen/skills(user)./.fen/skills(project)~/.pi/agent/skills,~/.agents/skills,~/.claude/skills,~/.codex/skills(user).pi/skills,.agents/skills,.claude/skills,.codex/skillsin the working directory first and then each ancestor through and including the nearest VCS root, with the nearest copy winning; without a VCS marker, traversal continues through the filesystem root (project)- paths passed with
--skill <path> - bundled fen skills, materialized under
${XDG_DATA_HOME:-~/.local/share}/fen/skills/bundled
Skills are deduplicated by canonical path and then by name; the first match wins, and each winner records later copies in its shadowed-paths list in discovery order. Use fen list skills --json or fen show skill <name> --json to inspect a winning skill's shadowed copies. Because bundled skills scan last, any user, project, or --skill copy with the same name shadows the bundled one. Set FEN_DISABLE_BUNDLED_SKILLS=1 to skip bundled-skill materialization and discovery.
Discovery stops descending at a directory that contains SKILL.md. It skips dotdirs, node_modules, and paths matched by .gitignore, .ignore, or .fdignore. .pi/skills also accepts direct *.md skill files; the other roots require <name>/SKILL.md directories.
Frontmatter #
Frontmatter is minimal YAML. description is required; name is optional and falls back to the directory (or file) name. disable-model-invocation: true keeps a discovered skill out of the prompt catalogue and the skill tool. Other keys, such as Claude Code's user-invocable, are ignored.
The description is the only text the model sees before choosing a skill, so say what the skill does and when to use it.
Prompt and loading #
The system prompt carries a compact catalogue of visible skills: one - name: description line each, without paths. The model loads a skill's full instructions with the skill tool ({name: "<skill>"}), which it activates through tool_search. The tool result includes the skill's directory so relative references inside the skill resolve with read.
Subcommands #
| command | effect | |||||
|---|---|---|---|---|---|---|
/skills | Open the skills picker/detail panel. | |||||
/skills <name> | Jump directly to one skill. | |||||
/skills list | Text list with scopes, visibility, and paths. | |||||
| `/skills visible\ | hidden\ | builtin\ | user\ | project\ | cli` | Filtered list views. |
The extension also exposes a discovered-skills introspection snapshot for /extensions skills and diagnostics.
Steering queue service #
The first-party steering extension (extensions/behaviors/kernel/steering/) owns the interactive input queues: steering lines injected into the running turn at safe boundaries, and >-prefixed follow-up lines started as fresh turns after the current turn completes. Extracting it moved the queue tables, drain modes, and >-prefix policy out of main.fnl's run state (issue #53 phase 1); the run loop only wires the agent's get-steering/get-follow-up callbacks and acts on the :start decision.
The service API lives in fen.extensions.steering.service:
| function | effect |
|---|---|
(submit line ctx) | Decide non-slash input: {:action :start} when idle, else queue (steering, or follow-up after stripping >). |
(handle-input input ctx) | Input-pipeline wrapper around submit; registered as the default :input-handler at order 1000 (see below). |
(get-steering) / (get-follow-up) | Drain a queue by its mode; wired as the agent callbacks. |
(queue! kind text) | Append a line and emit :queued plus refreshed status counts. |
(requeue! text from to) | Move the latest still-pending copy of a line to the other queue, emitting the same events as queue!. |
(clear-queues! ?kind) | Empty one queue or both (/cancel-all, /new, /resume, /handoff). |
(set-queue-mode! kind mode) | :one-at-a-time (default) or :all. |
(queue-info) / (queue-snapshot) | Counts+modes for status, copied contents for UI such as the /queue panel. |
Queue state lives in the non-reloadable fen.extensions.steering.state, so pending input survives /reload. Cross-extension consumers (the /queue inspector, sessions/handoff resets, the agent_state tool) require the service module rather than the fen.extensions.steering entry: the loader cache-busts entry modules on a fresh load!, while non-entry modules keep one table identity that /reload mutates in place.
Input-handler pipeline #
Non-slash user input is dispatched through an ordered :input-handler pipeline before a turn starts (issue #53 phase 2), rather than through the notification event bus — api.emit ignores return values, so it is a poor fit for ordered input transforms/intercepts. The run loop calls fen.core.extensions.input.handle with {:kind :user-input :text line} and a small runtime-owned context {:busy? bool :state runtime-state}.
Handlers register with (api.register :input-handler {:name ... :order ... :handle fn}) and run in ascending :order. Each returns a structured action:
{:action :continue :input modified-input}— pass (possibly transformed) input to the next handler.{:action :consumed}— swallow the input; no turn starts.{:action :ignore}— explicit no-op; stop the chain without starting a turn.{:action :start :text text}— start a new turn.{:action :queued :queue :steering|:follow-up :text text}— the handler already queued it.{:action :error :error message}— reject with a message.
The first non-:continue action wins; a handler that throws is skipped rather than wedging input. If every handler passes, the runtime starts a turn with the final text. The steering extension registers the default/fallback handler at order 1000, so other extensions (macro expansion, planners, subagent routing) can run before it.
Experimental: decide #
The first-party decide extension (extensions/behaviors/kernel/decide/) provides fast advisory judgment calls using either TypeSafe's Jev model on OpenRouter or the OpenAI Decisions API (gpt-6-luna). These decisions replace fixed heuristics or avoid full-context calls to the main model for tasks like tool-result pruning, auto-compaction timing, topic-shift hints, and busy-line routing. It is an experiment and is not recommended for general use yet. With the extension disabled, fen behaves as if it did not exist. Decisions never touch the main transcript or its prompt-cache prefix.
It is off by default. Enable it with /extensions enable decide or "extensions": {"decide": {"enabled": true}} in settings.json (see Discovery).
Backends and Credentials #
Decide supports two pluggable backends:
jev(default): TypeSafe's Jev on OpenRouter (POST https://openrouter.ai/api/alpha/decisions). Default model:~typesafe/jev-latest. Authenticates with theopenrouterprovider's key (OPENROUTER_API_KEY). Decision state is sent to TypeSafe through OpenRouter: excerpts of tool results, recent messages, and input lines.openai: OpenAI Decisions API (POST https://api.openai.com/v1/decisions). Default model:gpt-6-luna. Authenticates with theopenaiprovider's API key (OPENAI_API_KEY). Requires direct OpenAI API key credentials with Decisions API billing access. ChatGPT subscription OAuth credentials (fen --login openai-codex) or Codex subscription quotas do not grant access to this endpoint. Decision state is sent to OpenAI: excerpts of tool results, recent messages, and input lines. Billed at $0.10 per 1M input tokens during public beta, with no output-token or cache charges.
There is no silent backend fallback or cross-provider data transmission. If the configured backend fails, returns an error, or lacks credentials, the service returns nil and consumers retain their fallback behavior.
Settings under extensions.decide:
| key | default | meaning |
|---|---|---|
backend | "jev" | Decisions backend: "jev" (OpenRouter) or "openai" (OpenAI Decisions API). |
model | backend default | Decisions API model id (~typesafe/jev-latest for Jev, gpt-6-luna for OpenAI). |
timeoutMs | 3000 | Overall request timeout; the connect timeout is capped at 2000 ms. |
Consumers #
Every consumer is advisory: without an answer, fen keeps its behavior without decide.
- Compaction tool-result rating. Before compaction summarizes, each older tool result of at least 1 KiB is rated, and one rated at least 0.8 likely to be no longer needed reaches the summarizer as a one-line stub naming the tool, its arguments, and its original size. The stub exists only in the summarizer's copy; the session entry format is unchanged, and the
:compaction-summaryevent reports the count as:tool-results-dropped. A live measurement over 69 sessions (#512) found about 8% less summarizer input, with Jev's answers to this question mostly between 0.4 and 0.8. - Auto-compaction moment. Within the auto-compaction soft window, from 80% of
autoCompactTokensup to it, decide is asked once per turn, over the tails of the last few messages, whether the last subtask finished (e.g. checks passed) rather than work being mid-flight. An answer of at least 0.7 compacts if the runtime is still idle on the same turn and the context is still inside the window; a lower or missing answer waits for the next turn, and the threshold still applies. - Topic-shift hint. Each prompt submitted while idle after at least two earlier prompts is checked in the background for a move to an unrelated topic. The decision state is the new prompt plus the last three prompts and the reply that ended each, clipped. At a probability of at least 0.85, and only if no newer line was submitted or the conversation reset meanwhile, it emits a
topic changed · /handoff:hint; nothing runs until the user does. - Durable-preference hint. Each prompt submitted while idle is checked in the background for a durable preference meant to apply beyond the current task (e.g. "always use X", "never do Y", "from now on..."). At a probability of at least 0.85, and only if no newer line was submitted or the conversation reset meanwhile, it emits a
save as preference? · /prefer:hint; nothing is written until the user runs the command. The/prefercommand appends the preference to the project or user instruction file (AGENTS.md/CLAUDE.md) thatdefault-promptloads. - Busy-line classification. A plain line submitted while busy is classified as a correction, a follow-up, or a cancel request; slash and
>-prefixed lines never are. The line queues as steering at once, so classification never delays input. With confidence of at least 0.7, a follow-up still pending in steering moves to the follow-up queue through the steering service'srequeue!with an:infonotice, and/decide undomoves the last such line back to steering. Emptying the follow-up queue, as/cancel-all,/new,/resume, and/handoffdo, forgets that line. A cancel answer only emits a notice suggestingctrl-c; a correction, a lower confidence, anilanswer, or an answer arriving after the observed turn ended keeps today's routing. The question's state is the new line, the latest earlier user message (possibly an injected steering line, since the turn's original request is not marked), and the agent's latest step, each capped at 2000 bytes.
The compaction consumers live in fen.extensions.decide.compaction, the only decide module compact calls. The input consumers share one :input-handler at order 900, before the steering fallback, which never changes or holds the input; a failure is logged and the line proceeds.
Service API #
The service API lives in fen.extensions.decide.service; require it rather than the entry module, as with the steering service:
| function | effect |
|---|---|
(enabled?) | Whether the extension is loaded now; false after /extensions disable decide. |
(ask state questions ?opts) | Ask about one JSON-encodable state; returns answers keyed by question id, or nil. |
(ask-async! state questions on-done) | Run ask as a background task and call (on-done answers-or-nil). |
max-request-bytes | Cap on one encoded request (state plus questions); larger requests return nil without a call. |
questions maps a string id to {:type :noul|:choice :instructions text :criteria {...}}, several per request. A :noul question's criteria describe :true and :false; its answer is {:type :noul :noul p} with p the probability of true. A :choice question's criteria map each option to a description; its answer is {:type :choice :choice option :probabilities {option p} :confidence c}. Question wording and thresholds belong to each consumer.
Every failure returns nil and logs to the extension log: disabled extension, missing key, invalid questions, a request over the size guard, timeout, HTTP error, or a malformed or incomplete response. Callers keep their non-decide behavior on nil, so no decision may be required for correctness. Consumers own truncating their state to fit. The one exception is an error raised by the caller's yield function, such as a turn's cancellation, which propagates unchanged.
Pick the call style by what the caller already has:
askwith{:yield fn}suits code already running cooperatively with a yield function, such as a tool or compaction; the request advances between the caller's yields. Without a yield function it blocks for up totimeoutMs, which is what--printand json runs do.ask-async!suits code with no yield function, such as input or event handlers. The task advances one step per:runtime-tick, which only presenters that tick emit (TUI, web, rpc, session send, headless goal runs). While the extension stays loaded, the callback fires exactly once./reload, or disabling and re-enabling the extension, finishes any pending task withnilinstead of resuming it. Under--printor json the callback may never fire, so treat it as advisory.
Reload behavior #
/reload does two things:
- reloads eligible loaded core modules through
fen.core.extensions.loader.reload - asks
fen.core.extensions.loaderto reload each loaded extension
The model-facing reload tool only queues a request and requires both a scope (reload or registries) and a human-readable reason. The runtime records the request with api.log and executes one request at the idle boundary after the active agent turn, stream, and tool calls have finished. It dispatches the existing /reload command rather than reloading synchronously from the tool.
The default reload scope is ordinary /reload, so it preserves conversation history, active session metadata, persistent UI/runtime identity, logs, diagnostics, and trusted source overlays. The explicit registries recovery scope clears only extension event handlers, registration buckets, extension loader records, reload fingerprints, the active presenter slot, and the cached session-backend implementation. It preserves fen.core.extensions.state identity and all other buckets, including the session selection/info/handle, UI wrapper table, logs, errors, runtime info, and development overlay. Recovery then forces the normal core and extension bootstrap paths to rebuild registrations and the active presenter from known entries. The request/execution messages and reload summary identify the requested scope, reset buckets, preserved buckets, reload work, and failures. Restarting fen and using /resume remains the full-process escape hatch when scoped recovery cannot restore a usable runtime.
Every extension contribution is owner-tagged. Core stores this metadata in the reserved internal field :__owner; public introspection lists expose it as :owner. Do not put your own data in fields beginning with __.
Reload starts by removing all registrations for that owner, then re-runs the entrypoint. The built-in /reload command runs this work from a coroutine and passes a cooperative yield! callback into the loader so the TUI can keep painting between core module batches and fully reloaded extension specs. The active TUI presenter is skipped during the cooperative extension pass and then reloaded once at the end, followed by a single :reinit-presenter full redraw.
Extension code should follow the same rule: any command, hook, provider, tool, or register-time operation that may block should accept a ?yield-fn or use the callback supplied by the runtime, and call it between chunks of work. Avoid long CPU loops, blocking subprocess drains, large filesystem scans, or network waits inside presenter callbacks without yielding.
For module-shaped extensions, the manifest controls what is cleared from package.loaded:
:reload-modules [:fen.extensions.tui.markdown
:fen.extensions.tui.paint
:fen.extensions.tui.input
:fen.extensions.tui]
:reload-exclude [:fen.extensions.tui.state]
This lets behavior reload while persistent state survives. The TUI uses this to reload rendering/input/registration code while preserving termbox lifecycle, transcript, scroll position, and status state.
session-backend unregister has one extra side effect: if the removed backend is currently active, core clears the active backend and cached session info too. Keeping a stale active backend reference would route later appends into removed code.
Use /reload-extension <name> to reload one already-loaded external extension. The command rebuilds the agent afterward so changed tools and prompt fragments take effect while preserving conversation messages.
Introspection #
Interactive commands:
/docs [topic] [name]
/extensions
/extensions <name>
/extensions registry [kind]
/extensions enable <name>
/extensions disable <name>
/reload-extension <name>
/docs browses runtime documentation from live registries and structured contracts, including commands, tools, providers, events, canonical types, and register kinds. /extensions <name> shows manifest details plus the live commands, tools, panels, status items, prompt fragments, event handlers, hooks, and other registry contributions currently owned by that extension. /extensions registry [kind] shows the live registry grouped by kind, with stable owner labels for debugging reload cleanup and duplicate registrations. /extensions enable|disable <name> persist the settings flag described in Discovery.
Programmatic API:
(api.list :extensions)
(api.list :commands)
(api.list :tools)
(api.list :presenters)
(api.list :event-handlers)
(api.list :prompt-fragments)
(api.list :status)
(api.list :panels)
(api.list :hooks)
Lists are frozen deep copies intended for inspection, not mutation. Hot list kinds are memoized on a registry mutation counter, so repeated calls may return the same frozen snapshot until a register/unregister occurs.
Packaging and dependencies #
Flat manifest directories are the authoring shape for first-party and project-local extensions. Rockspecs remain useful for publishing and declaring extension dependencies, but users should build local extension dependencies via fen rather than invoking LuaRocks directly:
fen ext build .fen/extensions/myext
fen ext build DIR expects exactly one *.rockspec in DIR and installs into ${XDG_DATA_HOME:-~/.local/share}/fen/rocks by default. Set FEN_ROCKS_TREE to override the tree. Fen prepends the tree's Lua 5.4 share/lua and lib/lua paths to the runtime search path on startup when the tree exists.
fen ext build compiles the extension's Fennel sources in process with the embedded compiler (fen.core.extensions.build), so it needs neither a system fennel nor a fen source checkout, then installs the result through the bundled LuaRocks runtime. It drops a .lrbuild/.fen-precompiled marker so the rockspec's own build_command skips its bootstrap compile. That build_command is a one-line call into scripts/build/fennel-build.fnl for standalone luarocks make builds without fen, which set FEN_WORKSPACE to the fen source root; the single-file binary remains the canonical way to build extensions.
If extension loading fails with Lua's standard module 'X' not found error, the loader surfaces an actionable message: fen ext build <dir> when the extension directory has a rockspec, or a manual luarocks install --tree ... X command when it does not. Manifests may optionally declare :requires-modules [...] to probe lazy dependencies before loading and report all missing modules at once. :requires-shared-libs [...] is diagnostic text only; fen does not install system libraries.
The single-file binary bundles the local-only LuaRocks runtime, lfs, dkjson, and the LuaSocket modules needed by first-party presenters, so pure-Lua local rockspec builds do not need system luarocks. The bundled path intentionally does not include LuaSec or the luarocks.org network/download workflow. Native rocks still require a system C toolchain and Lua development headers; set LUA, LUA_INCDIR, and related LuaRocks variables when needed.
Minimal extension example #
A path-shaped extension under your user config extension dir. No :entry-module, no rock — just a directory with an init.fnl and, for global discovery, a manifest that enables it.
~/.config/fen/extensions/hello/
manifest.fnl
init.fnl
manifest.fnl:
{:name :hello
:description "Hello command"
:enabled-by-default true}
init.fnl:
(fn [api]
(api.register :command
{:name :hello
:description "Show a greeting"
:handler (fn [args _ctx]
(api.emit {:type :assistant-text
:text (if (= args "")
"hello"
(.. "hello " args))}))}))
For a project-local version, place the same directory under .fen/extensions/hello/; the manifest may be omitted entirely because the extension name falls back to the directory name and project-local drop-ins are enabled automatically.
For a tiny one-file project extension, use .fen/extensions/hello.fnl:
(fn [api]
(api.register :command
{:name :hello
:description "Show a greeting"
:handler (fn [args _ctx]
(api.emit {:type :assistant-text
:text (if (= args "")
"hello"
(.. "hello " args))}))}))
Run fen and type /hello world. In the source checkout, prefer the single-file dev wrapper:
nix build .#fen
FEN_BIN=$PWD/result/bin/fen scripts/dev/fen-dev
Installed/package users can run bin/fen directly.
For ad-hoc testing of an extension dir that isn't on a discovery root:
FEN_BIN=$PWD/result/bin/fen scripts/dev/fen-dev --extension /path/to/hello
--extension accepts a manifest dir or a single .fnl/.lua file, and always loads regardless of :enabled-by-default.
Claude Code memory interoperability #
The optional claude_memory extension exposes Claude Code's project memory index read-only. Enable it with /extensions enable claude_memory and inspect resolution with /claude-memory. It adds at most 8192 bytes of MEMORY.md, plus provenance and stale-context guidance, to the prompt; it never automatically imports linked memory bodies or writes memory files. Use existing file tools to read relevant linked files relative to the reported memory directory. Current project instructions take precedence; historical memories and Claude-specific orchestration guidance are not authoritative fen instructions.
By default, resolution uses the physical nearest Git worktree root (or the launch directory outside a repository), replaces every non-alphanumeric character with -, and looks in ~/.claude/projects/<project-key>/memory/MEMORY.md. Each worktree uses its own path key, not the main checkout's key; subdirectories within a Git worktree use its root. Claude path naming can vary, so no search of unrelated project directories or implicit main-worktree fallback is attempted. Set an absolute directory override when the mapping differs or when deliberately sharing main-checkout memories:
{"extensions": {"claude_memory": {"enabled": true, "directory": "/home/me/.claude/projects/-my-project/memory"}}}
Missing default indexes add nothing to the prompt and produce no startup notice. The inspection command reports missing/unreadable indexes, invalid overrides, inclusion, and truncation. Settings and the index are read when the prompt is assembled or inspected; /reload refreshes the extension normally. Enabling this extension sends the included index to the active model provider as prompt context, so review its contents first.