Lisp Extension Anatomy
On this page
You write one defextension form, drop it in an extensions directory, and run
/reload. Your command, tool, or event handler is live in the running session,
and /disable takes it out again with nothing left behind. That round trip —
add it, use it, remove it cleanly — is what an extension's structure is built to
guarantee. This page explains the structure that makes the guarantee hold.
The form
An extension is a defextension form with a name and clauses:
(defextension my-extension
(:requires ...)
(:provides ...)
(:metadata ...))
:requires lists what must already be present for the extension to install —
capabilities, providers, other extensions, tools. :provides lists what this
extension adds: its contributions. :metadata is an optional plist for
properties like (:autoload nil). Most extensions need only :provides;
requirements for the common contribution kinds are derived automatically, so a
command clause already carries its dependency on the commands capability
without you naming it.
The name matters beyond labeling. defextension greet binds a variable named
*greet-extension-manifest*. That variable does not hold an installed
extension. It holds a function of no arguments that, each time you call it,
builds a fresh extension value.
Manifest as value
The function bound to *greet-extension-manifest* is the manifest. Calling it
returns an extension value: a record carrying the id, the list of
requirements, and the list of contributions, with each contribution still inert
data. Nothing has touched the running session yet. You can build this value,
inspect it, pass it around, and discard it, and the session is exactly as it
was.
A thunk rather than a single prebuilt value is deliberate. Each call materializes new contribution objects with their own storage, so the same manifest installs into many protocols — a REPL, the production boot, a test, an agent's sub-session — without sharing mutable state between them. The manifest is the recipe; calling it bakes a fresh batch.
This is the same shape everywhere in kli. The model providers, the tools, the
slash commands, the terminal interface — each is a manifest bound to a
*...-extension-manifest* variable, and each is installed the same way you
would install your own. There is no privileged built-in path. You can read these
as real defextension forms: the builtin bash tool extension, the builtin theme
extension, and cairn as a full external one, a single manifest with a store-opening
effect, its model tools, a live context hot-patch, and its own slash commands, each
carrying a retractor.
The one step that mutates
Turning the value into running behavior is a single operation:
(install-manifest manifest protocol context)
install-manifest calls the thunk to get a fresh extension, then activates it
against the protocol. Activation is where every effect happens, and it happens in
a recorded way. It first checks the extension's requirements and errors if any
are unmet. Then it walks the contributions in order and installs each one,
pushing every installed contribution onto the protocol's record as it goes. If
any contribution fails midway, activation retracts the ones that already
installed and re-signals the error, so a failed install leaves the protocol where
it started rather than half-changed.
install-manifest returns the activated extension. That return value is the
handle you keep, because it is what removal takes:
(deactivate-extension protocol extension context)
Deactivation reads back the contributions this extension installed and retracts
each one, then forgets the extension. These two calls are the entire lifecycle.
Everything else — /install, /reload, /enable, profile bundles, the boot
sequence — drives these same two operations underneath.
Contributions and their retractors
A contribution is one unit of behavior an extension provides. The closed kinds the protocol knows directly are a model-callable tool, a capability provider, a provider contract, a generic-function method, a live object, and a raw effect. Other kinds — commands, event handlers, event types, keybindings, themes, message renderers, status slots, widgets, profiles, settings declarations — are themselves contributed by extensions that teach the protocol a new kind. A command, for one, is its own kind: installing it registers a slash command against the commands provider, and retracting it unregisters that command. The kind taxonomy is extensible the same way everything else is.
Every kind, without exception, pairs an installer with a retractor. Installing a
tool registers it and makes it callable; retracting it removes it from the
registry. Installing a capability provider files it under its name; retracting it
removes that entry. A method contribution installs a real method on the named
generic function and removes it with remove-method on retraction. The pairing
is not a convention you opt into — it is how each kind is defined, so there is no
kind that can be installed but not removed.
The effect kind is the general case for behavior that does not fit a named
kind:
(effect "my-effect" installer retractor)
The installer and the retractor are both functions of (protocol contribution
context). The installer's return value is stored as the contribution's state,
and the retractor reads it back through (kli/ext:contribution-state
contribution) to undo precisely what the installer did. The retractor is
required. When an effect genuinely has nothing to clean up you pass :no-op
explicitly, which records the choice rather than letting it pass silently. The
duty an effect author carries is symmetry: the retractor must drain exactly what
the installer created — unregister what it registered, restore what it replaced —
because the protocol will call it expecting the session to return to its prior
state.
Why reversibility is total
Removing an extension drains every contribution it installed and nothing else.
That holds because of how the two halves fit together. Installation records each
contribution as it lands, so removal has an exact list to work from rather than a
guess. Each kind defines its own retractor, so there is no contribution the
remover does not know how to undo. And state lives in per-protocol storage keyed
by the extension, not in global variables, so dropping the protocol drops the
state with it. A global defvar for extension state breaks this — a second
protocol overwrites it, and retraction cannot reach it — which is why it is
treated as a bug.
The payoff is the round trip you started with. Because installing is a recorded
transaction and every kind is reversible, /disable and /reload can take an
extension out mid-session and leave the rest of the session — your context, your
log, your model connection — untouched.
Where extensions load from
User extensions are plain Lisp files on disk. kli reads them from several roots.
Files under ~/.config/kli/extensions/ load in every session; files under
<project>/.kli/extensions/ load only in the project kli was launched in. Extra
roots come from the "extension-dirs" list in ~/.config/kli/config.json, and
kli --extension PATH loads one file or directory for a single run.
A single .lisp file is one extension. A directory containing
extension.lisp is one extension unit, loading its files in order with
extension.lisp last. A directory without extension.lisp is a group: its loose
files are single-file units and its subdirectories recurse. Exactly one
defextension per unit; zero or two is an error and the unit is skipped.
When kli loads a source file, it binds the defining protocol so the
defextension form not only binds its manifest variable but registers the
manifest for that protocol, which is what lets /reload re-index from disk and
reinstall the enabled set. Loading is fail-soft: a broken file warns and is
isolated while the rest keep working, so one bad extension does not take down the
others.
Related
- The Live Image — why behavior is editable while kli runs, the mechanism this anatomy rests on.
- Capabilities and Fault Barriers — the capability set that gates installing, retracting, and recoding.
- Profiles — how groups of extensions are bundled and selected.