Navigation
Copy page

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:

lisp
(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:

lisp
(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:

lisp
(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:

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

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