# kli — Full Documentation ## Learn ### Get started #### Teaching kli Your Project By default kli knows your code only by reading it. Anything not in the code — the test command, the project's name, a convention the team keeps — kli has to ask about or infer. An `AGENTS.md` at the repository root fixes that: kli reads it into the system prompt at startup, before your first message reaches the model. ## Write an AGENTS.md Put plain, true facts in `AGENTS.md` at the repo root — the things you would tell a new contributor on day one: ```md # Acme Parser A command-line tool that parses Acme log files into JSON. ## Commands - Run the test suite with `make test`. - Build the binary with `make build`. ## Conventions - Source lives under `src/`. Tests mirror it under `tests/`. - We use tabs, not spaces. ``` kli reads this verbatim, so a wrong fact becomes a wrong assumption later. The filename is exact: `AGENTS.md`. ## Pick it up A running session has already built its system prompt, so it will not see a file you just wrote. Start a fresh session from the same directory and the new one reads `AGENTS.md` as it boots. Ask something only the file knows: ``` How do I run the tests in this project? ``` The answer comes back as `make test`, drawn from the file rather than from searching the tree — kli already had it before you typed. ## Where context comes from `AGENTS.md` is the cheapest lever, but it is one input among several the system prompt is assembled from. [The agent loop](/kli/concepts/the-agent-loop) and [Context and the system prompt](/kli/concepts/context-and-the-system-prompt) show the rest, and [Inspect and edit context](/kli/guides/inspect-and-edit-context) shows how to see and change what the model is actually working from. ### Guides #### Choose and Switch Models kli runs against whichever model you select. You change that model in the running session with a command, and you make a choice stick across sessions by writing it into `settings.json`. This page covers both. A model is named by its provider and id together, written `provider/model` (for example `anthropic/claude-opus-4-8`). For the full list of providers, see [Models & Providers](/kli/models/providers-and-transports). Before a provider's models appear, that provider needs a credential. See [Connect a Provider](/kli/guides/connect-a-provider) for that step. ## See which models you can use Run `/models` to list every model you are authenticated for: ``` /models ``` Each line shows the `provider/model` reference and the model's display name. A line marked with `*` is the current selection. A line can also include a compact options marker, for example `options reasoning-effort`, naming the semantic options that model accepts. Pass a word to filter the list by provider id, model id, display name, or full reference: ``` /models opus ``` When nothing matches, the command says so rather than listing everything. To see the providers themselves and their authentication state, run `/providers`: ``` /providers ``` Each line gives the provider id, its auth status (`yes` when a credential is available, `no` when it is missing, `local` when the provider needs none), and the count of models it offers. A provider with `auth no` registers no usable models, so its models will not appear in `/models`. ## Switch the model for this session Give `/model` a reference to switch to it: ``` /model anthropic/claude-opus-4-8 ``` The reference does not have to be exact. If the text you type matches exactly one available model by id or substring, kli selects it; you can drop the provider when the model id alone is unambiguous: ``` /model claude-opus-4-8 ``` If your text matches more than one model, kli lists the candidates and changes nothing, so you can retype a longer reference. If it matches none, kli says so. In the terminal UI, bare `/model` opens a menu over the available models with the current one marked. Running `/model` bare without the UI prints the current selection followed by the model list. After a successful switch, kli prints a `Model:` system line confirming the new selection. The change applies from the next turn; it does not alter any earlier messages in the session. ## Set reasoning effort `reasoning-effort` is the semantic option behind model thinking. Models whose `/models` line includes `options reasoning-effort` accept the levels `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. Set one with `/thinking`: ``` /thinking high ``` Run `/thinking` with no argument to print the current level. In the terminal UI, bare `/thinking` opens a menu over the levels when the current model supports `reasoning-effort`. `/thinking off` turns reasoning off for the current model. You can also set the level in the same step as the model by appending it to a `/model` reference, separated by a space: ``` /model anthropic/claude-opus-4-8 high ``` Setting a non-`off` level on a model that does not support `reasoning-effort` is an error, and so is setting a level when no model is selected. Other semantic options are configured up front in `providers.json` model schemas and `settings.json` `defaultOptions`; `/thinking` is the interactive command for the option users change most often. ## Make a choice persist The commands above change only the running session. To start every session on a chosen model and option set, write the choice into `settings.json`: ```json { "defaultProvider": "anthropic", "defaultModel": "claude-opus-4-8", "defaultOptions": { "reasoning-effort": "high" } } ``` `defaultProvider` and `defaultModel` take effect together: kli looks up that exact model at startup and selects it. `defaultOptions` is an object keyed by semantic option id. Each option must be supported by the selected model; unsupported options and invalid values are ignored with warnings rather than failing startup. Set it in `settings.json` (global `~/.config/kli/settings.json`, project `/.kli/settings.json`; project wins) — see [Settings](/kli/config/settings). Put your usual default in the global file, then override it per repository when a particular codebase wants a different model or option set. A `/model` or `/thinking` command run during a session always takes precedence over these defaults for the rest of that session. For the full set of `settings.json` keys, see the [settings reference](/kli/config/settings). For where the model selection sits in a turn, see [The Agent Loop](/kli/concepts/the-agent-loop). #### Connect a Provider A model provider needs a credential before kli can call it. The `/auth` command registers that credential and reports whether the provider is now available. There are three ways to supply one: point at an environment variable, store a static API key, or run an OAuth login. You run these from inside a kli session. To see what is already registered, run `/auth` with no arguments. It lists each provider and reference and whether the credential resolves. ## Reference an environment variable Use this when the key already lives in your shell environment and you do not want kli to keep a copy. ``` /auth env ``` For example: ``` /auth env anthropic ANTHROPIC_API_KEY ``` kli registers a reference to the named variable and reads it fresh on every request. The value is never copied into kli's credential file, so it lives only as long as the variable does in the process environment. If the variable is unset, the provider reports as unavailable until you set it. ## Store a static API key Use this when you have a key string and want kli to keep it. ``` /auth key ``` For example: ``` /auth key openai sk-... ``` The key is written to the credential file and reused on later runs. kli does not echo the key back in its reply; it confirms only the provider and whether the credential is available. ## Run an OAuth login Some providers authenticate with OAuth instead of a key. kli uses PKCE and never runs a local callback server: it gives you a URL, you authorize in a browser, then you paste the result back. Start the login: ``` /auth login ``` For example: ``` /auth login openai-codex ``` If a stored OAuth credential already exists for that provider, kli restores it and reports the provider as available. Otherwise it prints an authorization URL. Open the URL, complete the sign-in, and the provider's page hands you an authorization code (or a redirect URL containing one). Paste that back to finish: ``` /auth code ``` `/auth code` accepts the bare code, a `code#state` pair, a `code=...` query string, or the full redirect URL; kli pulls the code out of whichever form you paste. It exchanges the code for tokens, stores them, and reports the provider as available. The pasted input is never echoed. `/auth code` completes the single pending login. Run one `/auth login` at a time: if more than one is in flight, kli asks you to finish them individually before it will accept a code. ## Remove a credential ``` /auth logout ``` This deletes the provider's stored credential from the credential file. An environment-variable reference is not stored, so there is nothing to delete for one; unset the variable instead. ## Where credentials are stored Stored API keys and OAuth tokens persist to `~/.config/kli/credentials.json`, written with file mode `0600` (owner read/write only). Environment-variable references are not written there — they resolve from the live environment each time. `/auth` itself is hidden from the model, because its argument can carry a raw key. For the providers kli ships and which auth mode each uses, see [Models & Providers](/kli/models/providers-and-transports). Once a provider authenticates, its models become selectable. See [Switch Models](/kli/guides/choose-and-switch-models) to choose one, and [Settings Reference](/kli/config/settings) for configuration that is set up front rather than in a session. #### Add a Custom OpenAI-Compatible Provider The `compatible` provider reads `~/.config/kli/providers.json` and registers one entry per OpenAI-compatible endpoint you define there — a local server (Ollama, llama.cpp, vLLM), a self-hosted gateway, or any third-party service that speaks the OpenAI Chat Completions or Responses wire format. For the rest of kli's providers, see [Models & Providers](/kli/models/providers-and-transports). This guide writes that file and connects the result. ## Create providers.json Create `~/.config/kli/providers.json`. The file is a single JSON object keyed by provider id. Each key becomes a provider name you select against; each value describes one endpoint. A minimal entry for a local Chat Completions server: ```json { "local-llama": { "base-url": "http://localhost:11434/v1", "key-env": "LOCAL_LLAMA_KEY", "models": [ { "id": "llama3.1:70b", "context-window": 131072 } ] } } ``` The key `local-llama` is the provider id. kli sends requests to the endpoint built from `base-url`, and reads the API key from the environment variable named in `key-env`. ## Set the request URL kli builds the request URL from `base-url` plus a fixed path determined by `api`. Set `base-url` to the root your server exposes, with no trailing path component: - For the Chat Completions API (the default), kli appends `/chat/completions`. A `base-url` of `http://localhost:11434/v1` yields `http://localhost:11434/v1/chat/completions`. - For the Responses API, kli appends `/responses`. Set `url-path` to append a different segment instead. Any trailing slash on `base-url` is trimmed before the path is joined. ## Choose the wire format Set `api` to match the format your endpoint speaks. The default is `openai-completions`. ```json { "my-gateway": { "base-url": "https://gateway.example.net", "api": "openai-responses", "url-path": "/v1/responses", "key-env": "GATEWAY_KEY", "models": [ { "id": "gpt-oss-120b", "context-window": 131072 } ] } } ``` The entry above sends requests to `https://gateway.example.net/v1/responses`: kli trims any trailing slash from `base-url` and joins `url-path` onto it. `url-path` only takes effect when `api` is `openai-responses`. With `openai-completions` it is ignored and the path is always `/chat/completions`. A value of `api` other than `openai-completions` or `openai-responses` is an error and the entry fails to load. ## Provide the API key A `compatible` entry never holds a secret. The `key-env` field names an environment variable; kli reads the key from that variable's value at request time. Set it in the shell or service unit that launches kli: ```sh export LOCAL_LLAMA_KEY="sk-..." ``` For a local server that ignores authentication, name an env var and set it to any non-empty placeholder. If you omit `key-env`, the entry registers with no environment credential. You can then persist a static key for that provider id from inside a session: ``` /auth key local-llama sk-... ``` The static key is stored and reused; it is not echoed back. ## List the models Each entry needs a `models` array. An entry with an empty array registers the provider but offers nothing to select. Each model is an object: ```json { "id": "llama3.1:70b", "name": "Llama 3.1 70B", "context-window": 131072 } ``` - `id` — the model id sent to the endpoint, and the id you select against. Required. - `name` — the display label. Defaults to `id` when omitted. - `context-window` — the context size in tokens, used for budgeting. - `options` — optional semantic model option schemas for this model. See [Add semantic options](#add-semantic-options). For where this file sits among kli's config paths, see [Files and paths](/kli/config/files-and-paths). ## Add semantic options Semantic options describe model capabilities in kli terms. They are not raw request fields. The selected transport lowers them to the wire shape it supports: for example, `reasoning-effort` becomes OpenAI `reasoning_effort` or Responses `reasoning.effort`, while Anthropic maps it to its `thinking` request object. Add an `options` object at the provider level to give every model the same option schema: ```json { "my-gateway": { "base-url": "https://gateway.example.net/v1", "api": "openai-responses", "key-env": "GATEWAY_KEY", "options": { "reasoning-effort": { "values": ["off", "low", "medium", "high"], "default": "off" }, "text-verbosity": { "values": ["low", "medium", "high"] } }, "models": [ { "id": "fast-model", "context-window": 65536 }, { "id": "plain-model", "context-window": 32768, "options": { "reasoning-effort": null } } ] } } ``` A model-level `options` object merges over the provider-level object. Re-declaring an option replaces that option's schema for just that model. Setting an inherited option to `null` removes it from that model. Each option schema may carry: | Field | Type | Effect | | --- | --- | --- | | `type` | string | Option type. Usually omitted for built-in semantic options, whose type is known globally. | | `values` | array | Admitted enum values. Required for enum options. | | `default` | scalar | Default value when the model is selected without an explicit value. | | `min` | number | Minimum for numeric option types. | | `max` | number | Maximum for numeric option types. | The schema type can be `enum`, `boolean`, `integer`, `number`, or `string`. Built-in semantic option ids are `reasoning-effort`, `text-verbosity`, `service-tier`, and `prompt-cache-retention`; their enum universes and transport lowering are documented in [Providers and Transports](/kli/models/providers-and-transports#semantic-options). ## Add extra headers To send headers on every request to an endpoint — a routing tag, an org id, a gateway token — add a `headers` object. Each name and value is appended to the outgoing request, alongside the `Authorization: Bearer` header kli sets from the resolved key. ```json { "my-gateway": { "base-url": "https://gateway.example.net/v1", "key-env": "GATEWAY_KEY", "headers": { "x-org-id": "acme", "x-route": "fast" }, "models": [{ "id": "fast-model", "context-window": 65536 }] } } ``` ## Use the provider kli reads `providers.json` when the `compatible` provider installs, so start a fresh session after editing the file. Each entry appears under its provider id, with its models available to select. See [/kli/guides/connect-a-provider](/kli/guides/connect-a-provider) for switching providers and models inside a session. #### Run Commands and Eval Lisp kli has two tools for executing code in a session: `bash` runs a shell command in a child process, and `eval` evaluates Common Lisp forms inside the running kli image. The agent calls them while it works, and you can call either one directly with a slash command. Both tools are gated behind capabilities. They run only when the session grants them: `bash` needs `process/exec` and `eval` needs `image/eval`. With the default settings (no `capabilities` key) every tool is allowed. See [Restrict Tools With Capabilities](/kli/guides/restrict-what-kli-can-do) to deny one. ## Run a shell command Type `/bash` followed by the command: ``` /bash ls -la src ``` Everything after `/bash` is the command line, passed to `sh -c`. So pipes, redirects, globs, and `&&` work as written: ``` /bash grep -rn TODO src | head -20 ``` The tool returns stdout on success. When the command exits non-zero, the result is marked an error and includes stderr. An empty result means the command produced no output and exited zero. ## Set the working directory or pass stdin The agent can run a command in a specific directory or feed it input. These are tool parameters, not shell syntax, so the agent supplies them on the tool call rather than you typing them after `/bash`. The `bash` tool accepts: | Parameter | Required | Effect | |-------------|----------|----------------------------------------------------| | `command` | yes | The command line to run. | | `directory` | no | Working directory for the child process. | | `input` | no | Text written to the command's stdin. | | `shell` | no | Shell to invoke. Defaults to `sh`. | From the `/bash` slash command, only the command line is available; the rest take their defaults. To change the working directory yourself, `cd` inside the command itself. ## Evaluate Common Lisp Type `/eval` followed by one or more forms: ``` /eval (+ 1 2 3) ``` The forms are read and evaluated in order. The result is whatever the forms printed, followed by the value of the last form. Several forms in one call run left to right: ``` /eval (defparameter *x* 10) (* *x* *x*) ``` Forms read and evaluate in the `CL-USER` package by default. The agent can target another package through the tool's `package` parameter; unqualified symbols then intern there. Values print under bounded printer control: `*print-length*` is 100, `*print-level*` is 20, and `*print-circle*` is on, so a long or circular value prints a bounded representation instead of running away. The forms run in the same image kli runs in, so they reach live state and can inspect or change kli mid-session. For what that image is and why it matters, see [The Live Image](/kli/concepts/the-live-image). ## Stay inside the timeouts Both tools time out at 30 seconds by default. The hard maximum is 300 seconds; a longer request is clamped to it. There is no way to extend a single call past 300 seconds. When a `bash` command times out, kli kills its whole process group (SIGTERM, then SIGKILL) and returns the output captured so far, marked as a timeout error. When an `eval` form times out, kli interrupts the evaluating thread. An interrupted form can leave image state partially modified, since it stops wherever it was, so the result says so. To run something longer than 300 seconds, start it in the background from a `bash` command and poll its progress with later commands, rather than waiting inside one call. ## Know the output cap Each tool caps its captured output at 1 MiB (1,048,576 characters): - `bash` caps stdout and stderr at 1 MiB **each**. Past the cap the stream is truncated and the result notes `[stdout truncated at ... characters]`. - `eval` caps total output at 1 MiB across everything the forms print. The cap applies as output is written, so even a non-terminating printing loop stops adding to the result at the cap. When you expect a large result, narrow it before it reaches the tool: pipe a `bash` command through `head`, `tail`, or `grep`, and have `eval` return a count or a slice rather than a whole collection. ## Commands bash refuses The `bash` tool runs to completion and returns captured output; it has no terminal to drive. So it refuses commands whose first word is an interactive program and returns an error instead of hanging: ``` vi vim nvim nano emacs less more man top htop watch ssh mosh tmux screen ``` The check looks past leading variable assignments and wrappers (`env`, `command`, `exec`, `time`, `sudo`, `doas`) to find the real command, so `sudo vim file` is refused too. Reach for non-interactive equivalents: read a file with `cat` instead of `less`, search with `grep` instead of opening an editor, run a remote command through whatever your environment exposes rather than an interactive `ssh` session. #### Persist and Resume Sessions By default kli keeps the session log in memory and discards it when you quit. To carry a conversation across runs, point kli at a directory to write session files into, then reopen a saved session by id or resume the newest one on boot. ## Persist sessions to disk Set `sessionDir` in your settings so kli writes each session to a file instead of holding it in memory. Set it in `settings.json` (global `~/.config/kli/settings.json`, project `/.kli/settings.json`; project wins) — see [Settings](/kli/config/settings): ```json { "sessionDir": "~/.config/kli/sessions" } ``` A leading `~` expands to your home directory; a relative path resolves against the directory you start kli in. The directory is created on first write. Restart kli for the change to take effect. From then on, kli writes one file per session, named `.session`, under that directory. Each file is a header followed by one record per turn, appended as the conversation grows. To check whether the current session is being written, run `/session`. It reports the session id, the file path backing it, the active model, and the token count. A session with no `sessionDir` configured shows `file: (memory)`. For the full settings schema, see [Settings](/kli/config/settings). ## Resume a saved session Resuming switches the current session onto a stored one. There are three ways in, depending on whether you know the id and whether you are at the terminal UI. **Resume the newest session on boot.** Pass `-c` (or `--continue`) when you start kli: ```sh kli -c ``` kli resumes the most recently stored loadable session. With no stored sessions, it starts fresh. **Pick from a list in a running session.** Run `/resume` with no argument. In the terminal UI this opens a menu over your stored sessions, newest first. Without the UI, `/resume` prints the list as text rows, each marked with `*` for the active session and showing the id, name, message count, and a preview of the opening prompt. **Resume a specific session.** Pass an id or a search term: ```text /resume session-12 /resume refactor ``` An exact id resumes that session. Otherwise kli searches ids, names, and previews: a single match resumes, several matches print the narrowed list to choose from. To name a session so it is easy to find later, run `/name `. To remove a stored session, run `/resume delete ` — you cannot delete the session you are currently in. ## Branch and rewind through past turns Rewinding steps the conversation back to an earlier point. kli does not overwrite history when you do this: it branches the session before the chosen prompt and switches onto the new branch, so the turns you stepped back from remain on the original session. **Step back the latest turn.** Run `/rewind` with no argument to undo the most recent user turn. **Step back several turns.** Pass a count: ```text /rewind 3 ``` This rewinds to before the third-newest prompt. If the session has fewer turns than the count, kli reports nothing to rewind. In the terminal UI, bare `/rewind` opens a menu over the session's user turns. **See and switch between branches.** Run `/branches` to view the tree of sessions that rewinding has produced. In the terminal UI this opens a selection menu over the tree; accepting a row switches onto that branch. Without the UI, `/branches` prints the tree as indented rows, marking the active session with `*` and labelling each branch by the prompt it diverged from. Branching and rewinding only persist across restarts when `sessionDir` is set. In memory the branches still work, but they vanish on exit along with the rest of the log. ## Related - [/kli/cli/installation](/kli/cli/installation) — install kli and start it in a project. - [Settings](/kli/config/settings) — every settings key, with types and defaults. - [Sessions as a Tree](/kli/concepts/sessions-as-a-tree) — what a session is and how the log is structured. #### Manage Long Conversations A long session fills the model's context window. kli summarizes older history to make room, on demand or automatically, and keeps the recent part of the conversation intact. Compact when you want, let kli compact on its own near the limit, and label a session so you can find it later. ## Compact the session now Run `/compact` to summarize the conversation's older history immediately: ``` /compact ``` kli runs one summarizer model call over everything except the most recent turns, replaces that history with a structured summary, and keeps the recent turns verbatim. The summary records the goal, constraints, progress, key decisions, next steps, and critical context (file paths, function names, error messages), so the agent continues from a checkpoint rather than from raw transcript. Compact when the session is idle. If a turn is still running, the command reports that the session is busy; wait for the turn to finish and run it again. When there is nothing older than the recent window to summarize, `/compact` reports that there is nothing to compact. ## Focus the summary Pass a tail to `/compact` to add a focus line to the summarizer's instructions: ``` /compact keep the database migration details and drop the UI styling discussion ``` The focus rides along with the standard summary format. Use it when one thread of the conversation matters more than the rest and you want the summary to preserve it. ## Let kli compact automatically You do not have to run `/compact` yourself. At the end of each turn, kli checks token usage against the context window. When usage reaches a configured fraction of the model's context window, kli compacts on its own: it keeps the recent tokens and summarizes everything older, the same operation `/compact` performs. Automatic compaction stays quiet when there is nothing older than the recent window to summarize. A manual `/compact` is the way to summarize before the threshold, for instance right before handing the session a large new task. To see current usage at any time, run `/session`, which prints the active session's id, file, model, and token count. ## Name the session Run `/name` with a label to set the session's display name: ``` /name fourier-series refactor ``` The name is written to the session header and persists to disk, so it identifies the session in `/resume` and `/branches` listings. Run `/name` with no argument to print the current name, or report that none is set. ## Related - [Sessions](/kli/concepts/sessions-as-a-tree) — what a session is and how it is stored - [The Agent Loop](/kli/concepts/the-agent-loop) — how a turn runs and when usage is measured - [Session Commands](/kli/commands/slash-commands) — the full reference for `/compact`, `/name`, `/session`, `/resume`, and `/rewind` #### Inspect and Edit Context The model only ever sees a projection of your session: the ordered list of messages kli builds and sends on the next turn. The `/context` command shows you that projection and lets you change it. Edits are staged first and applied as a group, so you can review or discard them before they reach the model. This guide covers `inspect`, `stage`, `diff`, `commit`, and `revert`. ## View the current projection Run `/context inspect` to see what the model would receive right now: ``` /context inspect ``` The header reports three numbers: ``` Context epoch 4, 12 messages, 0 staged. ``` The **epoch** counts how many times you have committed edits to this context; it starts at 0 and increments by one per commit. The **message count** is the length of the projection. The **staged** count is how many edits are waiting to be applied. When edits are staged, `inspect` lists them under a `Staged:` block. Positions are zero-based; target a message by index for removal or replacement. ## Stage an edit `/context stage` takes one of three subcommands as its first word. Staging records the edit but does not change the projection yet. Add a message to the end of the projection: ``` /context stage append ``` The text becomes a user message appended after the current last message. Remove the message at a given index: ``` /context stage remove ``` Replace the message at a given index with new text: ``` /context stage replace ``` Each stage command confirms the running total, for example `Staged append-message patch (2 total).` Stage as many edits as you need; they accumulate in order. ## Review staged edits Before applying anything, see exactly what is pending: ``` /context diff ``` This lists every staged edit without committing. An append shows the text to be added, a remove shows the target index, and a replace shows the new text. With nothing staged, it reports `No pending context changes.` ## Apply the edits When the staged set is what you want, commit it: ``` /context commit ``` Commit applies all staged edits to the projection as a single group, clears the staging area, and bumps the epoch by one. It reports the count and the new epoch, for example `Committed 3 patches (epoch 5).` If nothing is staged, it reports `No staged patches to commit.` A commit is recorded in the session log, so the edited projection survives across saves and resumes. Removal and replacement match the message sitting at the given index when the commit rebuilds the projection. If no message sits at that index, the edit applies to nothing and the projection is unchanged, so use `/context inspect` first to read off the index you mean. ## Discard staged edits To throw away everything you have staged without applying it: ``` /context revert ``` This clears the staging area and reports how many edits it discarded. It does not touch already-committed changes; only the pending set is affected. With nothing staged, it reports `No staged patches to revert.` ## Capabilities these commands need Inspecting, staging, and committing each need a capability; `diff` is ungated and always runs. With the default settings the `capabilities` key is absent, so every subcommand is allowed. If you restrict tools through the `capabilities` array in `settings.json`, list the ones you want: | Subcommand | Capability | | --- | --- | | `inspect` | `context/read` | | `stage`, `revert` | `context/stage-edit` | | `commit` | `context/commit-edit` | See [Restrict Tool Capabilities](/kli/guides/restrict-what-kli-can-do) for how the `capabilities` array works, and [The Agent Loop](/kli/concepts/the-agent-loop) for where the projection sits in a turn. #### Steer a Running Turn kli keeps reading your input while a turn is in flight. You do not have to wait for the model to finish before you correct its course, add a constraint, or stop it. This page covers the three ways to redirect a turn that is already running: steering, follow-up, and aborting with Esc. ## Steer: inject guidance at the next tool boundary Type a message and press Enter while kli is working. The turn keeps running, and your message is queued as steering. kli delivers it at the next tool boundary: after the current tool call returns, before the model issues the next one. The remaining tool calls in that batch are skipped, your message lands in the conversation, and the model reacts to it on the following turn. A turn that makes no tool calls has no mid-turn boundary. A steer queued during such a turn is delivered when the turn ends, so it runs as the next turn rather than waiting for a boundary that never comes. Steering is the default for anything you submit while a turn is in flight. You do not press a special key or prefix the message; an ordinary Enter during a running turn steers. ## Follow-up: queue a message for after the run A follow-up message is held until the whole turn completes, then delivered as the next turn. Unlike steering, it never interrupts tool calls or cuts a batch short. Use it when the current work is correct and you want to add the next instruction without disturbing what kli is already doing. ## Set the delivery mode Both queues default to delivering everything you have submitted in one pass when their boundary arrives. If you submit three steering messages while kli works, all three land together at the next tool boundary. You can switch either queue to deliver one message per boundary instead. Set these in `settings.json` (global `~/.config/kli/settings.json`, project `/.kli/settings.json`; project wins) — see [Settings](/kli/config/settings): ```json { "steeringMode": "one-at-a-time", "followUpMode": "all" } ``` `steeringMode` controls how many queued steering messages drain per tool boundary; `followUpMode` does the same for follow-up messages after the turn ends. Set either to `"one-at-a-time"` to deliver exactly one queued message per boundary, leaving the rest to drain at later boundaries. The default, `"all"`, drains every queued message in that queue at once. Leave a key out to take the default; an unrecognized value is ignored with a warning, and the default applies. For the full settings reference, see [Settings](/kli/config/settings). ## Abort or rewind with Esc Esc does one of two things depending on whether a turn is running. **While a turn is in flight, Esc aborts it.** The first press arms and shows `Press Esc again to interrupt.`; a second press within about 1.5 seconds stops the turn. A lone Esc does nothing, so a misclick cannot interrupt your work. If the window lapses, the next Esc re-arms. What the abort leaves behind depends on how far the turn had progressed: - If the model had not yet streamed any reply, the prompt you sent is un-sent: the row is removed from the transcript and your text is restored to the editor, so you can revise and resend it. If turn entries have already been committed, the prompt stays sent and kli shows an `Interrupted.` notice instead. - If the reply was already streaming, kli marks that reply as aborted in place and stops the turn. **At an idle prompt with a conversation behind you, Esc rewinds.** The first press shows `Press Esc again to rewind.`; a second press within the window opens a menu of your past prompts. Move with Up and Down, press Enter to rewind to the state before the chosen prompt, and Esc to dismiss the menu. Rewinding to before a prompt restores that prompt's text to the editor. On an empty conversation, or when there is nothing to rewind, Esc is a no-op. ## Related - [/kli/concepts/the-agent-loop](/kli/concepts/the-agent-loop) — why a turn has tool boundaries to steer at - [/kli/guides/persist-and-resume-sessions](/kli/guides/persist-and-resume-sessions) — the session log a rewind steps back through - [Settings](/kli/config/settings) — the full settings reference #### Restrict What kli Can Do By default kli can read, write, and edit your files and run shell commands. There is no per-action prompt to approve. You restrict it up front by listing, in `settings.json`, the exact set of permissions kli is allowed to hold. This guide shows how to write that list and confirm it took effect. The list is the `capabilities` key. Its value is an array of capability name strings. kli grants the agent exactly those capabilities (plus any they imply) and denies everything else. ## Decide where to put the setting Set it in `settings.json` (global `~/.config/kli/settings.json`, project `/.kli/settings.json`; project wins) — see [Settings](/kli/config/settings). Use the project file when you want one repository locked down without changing how kli behaves elsewhere. ## List the capabilities you want to grant Each gated tool checks for one capability before it runs. The everyday ones: | Capability | Lets kli use | |---|---| | `file/read` | the `read`, `find`, and `search` tools | | `file/write` | the `write` tool | | `file/edit` | the `edit` tool | | `process/exec` | the `bash` tool | Write the names of the capabilities you want into the array. Anything you leave out is denied. To let kli read files and run commands but never write or edit them: ```json { "capabilities": ["file/read", "process/exec"] } ``` Under this setting kli can read, search, and run shell commands. Calls to `write` or `edit` are denied, the agent is told so, and the operation does not run. Granting one capability can grant others. `tools/standard` is shorthand for all four of the file and process capabilities above, so this grants the same access as listing them individually: ```json { "capabilities": ["tools/standard"] } ``` For the full list of capability names, what each one gates, and which capabilities imply which, see [Capabilities](/kli/config/capabilities). ## Deny everything gated An empty array grants nothing. Every gated tool is then denied: ```json { "capabilities": [] } ``` This is the strictest setting. kli can still do work that checks no capability, but it cannot touch files, run commands, change its own extensions, or read credentials. ## Allow everything Omit the `capabilities` key entirely and kli runs with full access — the default. Removing the key from your settings file is how you lift a restriction: ```json { } ``` ## Apply the change The `capabilities` key is read when settings load. Restart kli, or switch the active profile, so the new array takes effect. After that, a tool whose capability is not in your list returns a denial instead of running, with no prompt to override it. If you write a value that is not an array of strings, kli ignores it with a warning and falls back to full access, so a typo never silently locks the agent down. Check kli's startup output for that warning if a restriction does not seem to apply. The `capabilities` array limits which tools an agent may reach; it does not isolate the kli process from the host. A granted shell command or file write still runs with your full privileges. To bound that, run `kli --print-authority` to see what a session will hold and put kli inside a sandbox — see [Security model and sandboxing](/kli/concepts/security-model-and-sandboxing). #### Work in the TUI You talk to kli through one editor at the bottom of the terminal. You type a prompt, send it, and the conversation scrolls above. This guide covers the keys you use every session, how to complete file paths and skill names as you type, how to fold and unfold tool output, and how to set the color theme. For the exhaustive key table see [Keymap](/kli/commands/keymap); for every color token see [Themes](/kli/commands/themes). ## Send, edit, and quit `Enter` sends the prompt. For a multi-line prompt, insert a line break with `Ctrl+J` or `Shift+Enter`, keep typing, and send the whole thing with `Enter`. The editor takes standard readline keys; see [Keymap](/kli/commands/keymap) for the full table and rebinding. `Ctrl+L` clears the screen. `Ctrl+C` takes two presses to quit: the first prints `Press Ctrl+C again to quit.`, the second exits. `Esc` depends on what kli is doing. While a turn is streaming, press it twice within about 1.5 seconds to interrupt the turn. At an idle prompt, press it twice to open the rewind menu over your earlier prompts; accept one to send the conversation back to before it. ## Complete a file path with @ Type `@` at the start of a token to reference a file. A menu of paths under the working directory opens as you type, ranked against what you have entered. In every completion menu, `Up`/`Down` move the selection, `Tab` or `Enter` accepts, and `Esc` dismisses. Hidden files stay out of the menu unless your text starts with a dot. Selecting a directory keeps the menu open and steps into it, so you can walk down a tree one segment at a time. Selecting a file inserts the path and a trailing space. `Esc` closes the menu and leaves what you typed. ## Complete a skill with $ Type `$` at the start of a token to run a skill. The menu lists the skills available in this session; each row shows the skill name and its description. Slash commands complete the same way. Type `/` and the command menu opens, then accepting a command offers help or candidates for its arguments. See [Commands](/kli/commands) for what is available. ## Complete a bare path with Tab When no completion menu is open, `Tab` completes the path token directly before the cursor — no `@` needed. One match is inserted at once; several open the menu to choose from. With a menu already open, `Tab` accepts the current selection instead. ## Fold and unfold tool output with Ctrl+O When kli runs a tool, the result is shown as a card in the transcript: a shell command's output, a file you asked it to read, the diff of an edit. By default these cards are collapsed so the conversation stays readable. Press `Ctrl+O` to expand every committed tool card to its full output, including the full diff of each edit. Press `Ctrl+O` again to collapse them. The toggle reprints the transcript, so it applies to the whole session at once rather than one card at a time. ## Let kli pick the theme kli ships a `dark` and a `light` theme. By default it reads your terminal's background color at startup and selects the matching one, so a light terminal gets the light theme without any configuration. Detection happens once per session and is skipped under GNU `screen`. If you do nothing, auto-detection stays on. To return to it after pinning a theme, set `theme` to `"auto"` or remove the key. ## Pin a theme To always use one theme regardless of the terminal background, set the `theme` key in `settings.json` to `"dark"` or `"light"`: ```json { "theme": "dark" } ``` Set it in `settings.json` (global `~/.config/kli/settings.json`, project `/.kli/settings.json`; project wins) — see [Settings](/kli/config/settings). A pinned theme turns off background detection for the session. An unknown name is ignored with a warning and the previous theme stays in effect. The setting is read at startup, so restart kli for a change to take hold. ## Rebind a key Bind a key through the `keybindings` object in `settings.json`. The keys are key-id strings like `ctrl+r`; the values are action names like `delete-to-line-start`: ```json { "keybindings": { "ctrl+r": "delete-to-line-start" } } ``` Each entry sets one key and leaves the rest of the defaults in place. An unknown action name is skipped with a warning. For every key-id, action name, and the default binding of each key, see [Keymap](/kli/commands/keymap). #### Run kli Headless or Piped You can drive kli without typing into a live terminal. Pipe a prompt in and kli runs one turn and prints the result. Redirect a file in and kli runs each line as its own turn. For longer-lived unattended work, you can also boot a profile that brings up kli without the terminal UI at all. ## Feed a prompt over a pipe Run kli the same way you always do, but connect its standard input to something other than a terminal: ```sh echo "summarize the changes in the last commit" | kli ``` kli starts in the default profile and brings up the agent, but instead of the interactive editor it detects that standard input is not a terminal and switches to a line loop. The line loop reads one line, submits it as a turn, waits for the agent to finish that turn, then reads the next line. When input reaches end of file, kli exits. Run kli from inside a project directory so it reads and edits that project's files, exactly as in an interactive session. ## Feed many turns from a file Because the line loop runs one turn per line, a file of prompts runs as a sequence of turns in one session, each turn seeing the results of the ones before it: ```sh kli < turns.txt ``` Each line in `turns.txt` is submitted in order, and the session ends at end of file. Keep one instruction per line. A blank line is submitted as an empty turn, so strip blank lines from the file if you do not want them. ## Resume a session in a piped run A piped run starts a fresh session by default. To continue the most recently stored session instead, pass `--continue` (or `-c`): ```sh echo "now run the test suite" | kli --continue ``` kli resumes the newest loadable stored session, then runs the piped line as the next turn in it. This lets a script pick up where an earlier interactive or piped run left off. See [Persist and Resume Sessions](/kli/guides/persist-and-resume-sessions) for how sessions are stored. ## Use kli inside a script The piped form composes with the rest of your shell. Build the prompt from other commands and pipe it in: ```sh git diff --staged | { echo "Write a one-paragraph commit message for this diff." cat } | kli ``` kli's own output goes to standard output, so you can capture or pipe it onward. Errors and boot diagnostics go to standard error, so redirect each stream where you want it: ```sh echo "list every TODO comment in src/" | kli > result.txt 2> kli.log ``` When stripping kli's reply down to a value a script can use, prefer prompts that ask for exactly the output you want and nothing else. ## Pick a boot profile A profile is the set of extensions kli installs at boot. The default profile, `interactive-terminal`, is the one a normal interactive session uses, and it is also the one the piped line loop above runs under. kli selects a profile from, in order: 1. the `--profile NAME` flag, 2. the `KLI_PROFILE` environment variable, 3. the `profile` key in `settings.json`, 4. the default, `interactive-terminal`. So a script can set the profile per invocation: ```sh kli --profile headless ``` or for a block of commands at once: ```sh export KLI_PROFILE=headless ``` For unattended runs, kli ships the `headless` and `autonomous` profiles, both of which boot without the terminal UI; the next two sections describe each. For the full roster and how to define your own, see [Switch and customize profiles](/kli/guides/switch-and-customize-profiles). ## Boot the headless profile The `headless` profile installs kli's baseline extensions and nothing else: no terminal UI and no model providers. It boots the kernel, holds it alive, and does not enter an interactive loop or read prompts from standard input. ```sh kli --profile headless ``` Use this profile to bring up kli as a long-running process you drive by other means rather than by typing or piping prompts. It is not the profile for running a one-shot prompt; for that, pipe into the default profile as shown above. Boot diagnostics that an interactive session would show in the transcript are printed to standard error instead. ## Boot the autonomous profile The `autonomous` profile installs the baseline extensions and the model providers, then declares a set of extension points it expects you to fill: `planner`, `scheduler`, `watchdog`, and `recovery`. Like `headless`, it boots without the terminal UI. ```sh kli --profile autonomous ``` The profile names those four points but does not provide them. It is a starting point for an unattended agent that you complete by installing extensions that supply a planner, a scheduler, a watchdog, and a recovery strategy. Without those, the profile boots the baseline agent and the providers but has no driver of its own. See [Install a Remote Extension](/kli/extend/sharing-extensions) for how extensions are added. ## Related - [The Agent Loop](/kli/concepts/the-agent-loop) — what one turn does. - [Connect a Provider](/kli/guides/connect-a-provider) — a model credential the default and autonomous profiles need. - [Persist and Resume Sessions](/kli/guides/persist-and-resume-sessions) — how `--continue` finds a session. - [Installation](/kli/cli/installation) — getting the `kli` binary onto the machine running your script. #### Switch and Customize Profiles A profile is the set of extensions kli installs at boot: which model providers load, whether the terminal UI comes up, which tools are present. kli ships these builtins: - `interactive-terminal` — the default. Boots the terminal UI with all the model providers and the full tool set. - `headless` — the baseline kernel and tools with no terminal UI and no provider extensions. Holds the process open for a programmatic driver. - `human-in-loop` — the interactive profile, declaring an `approval` seam for an extension you supply. - `autonomous` — providers and tools without the terminal UI, declaring `planner`, `scheduler`, `watchdog`, and `recovery` seams. This guide shows how to choose one at startup, switch while kli runs, and define your own. ## Choose a profile at startup Name a profile on the command line: ```sh kli --profile headless ``` Or set it in the environment, which kli reads when `--profile` is absent: ```sh export KLI_PROFILE=headless kli ``` To make the choice stick without a flag or env var, set the `profile` key in `settings.json`: ```json { "profile": "headless" } ``` kli resolves the boot profile in this order, taking the first that names one: 1. `--profile ` 2. `KLI_PROFILE` 3. the `profile` key in `settings.json` 4. the default, `interactive-terminal` Set it in `settings.json` (global `~/.config/kli/settings.json`, project `/.kli/settings.json`; project wins) — see [Settings](/kli/config/settings). A name that resolves to nothing warns and boots the default profile instead. The warning lands in the transcript as a boot diagnostic. ## Switch profiles while kli runs Run `/profile` with no argument to list every profile, builtins first, then the ones you defined. The active one carries a `*`: ``` Profiles: * interactive-terminal headless human-in-loop autonomous review ``` Pass a name to switch: ``` /profile review ``` A live switch re-bases your own extensions onto the target profile: kli installs the ones the target enables that are not yet present, and retracts the ones present that the target disables. The reply names what changed: ``` Switched to review. Installed: my-linter. ``` The delta is computed against what is actually installed, so any manual `/enable` and `/disable` you ran since boot re-bases too. Two switches do not apply: - Naming the active profile reports that it is already active and changes nothing. - A profile whose builtin base differs from the running one cannot apply live, because the base set installs only at boot. The builtins each have a distinct base, so a live switch between them always lands here. kli tells you to restart, for example `Restart with --profile autonomous`. ## Define your own profile Add a `profiles` block to `settings.json`. Each key is a profile name; each value describes how it differs from a builtin: ```json { "profiles": { "review": { "extends": "interactive-terminal", "enable": ["my-linter"], "disable": ["scratch-notes"], "settings": { "defaultProvider": "anthropic", "defaultModel": "claude-opus-4-6" } } } } ``` The fields: - `extends` — the profile this one builds on, a builtin or another profile you defined. Omit it and the profile builds on `interactive-terminal`. The chain must bottom out at a builtin; a cycle or a dangling target is reported and the profile is skipped. - `enable` — ids of your own extensions to install that the base would leave out. - `disable` — ids of your own extensions to keep out that the base would install. Within one profile `disable` wins over `enable` for the same id. `enable` and `disable` gate the extensions kli discovers in `~/.config/kli/extensions/` and `/.kli/extensions/`, not the builtin tools. - `settings` — a settings object that overlays the merged files while this profile is active. Use it to bind a profile to a default provider and model, a set of capabilities, or any other setting. A profile may not reuse a builtin name (`interactive-terminal`, `headless`, `human-in-loop`, `autonomous`). kli warns and ignores any entry that does. Once defined, a profile is selectable everywhere a builtin is: `--profile review`, `KLI_PROFILE=review`, the `profile` key, and `/profile review`. It also appears in the `/profile` list and in `/profile` tab completion. For how `enable` and `disable` sit against the per-extension `/enable` and `/disable` commands, see [Restrict What kli Can Do](/kli/guides/restrict-what-kli-can-do) and the [settings reference](/kli/config/settings). For what an extension is and why retracting one mid-session is safe, see [The Live Image](/kli/concepts/the-live-image). #### Update kli `kli update` replaces your installed binary with a newer release built from [github.com/kleisli-io/kli](https://github.com/kleisli-io/kli). It downloads the release for your platform, verifies it against the release checksums, and swaps it into place. If the new binary fails to run, kli restores the previous one before it returns. This applies to a binary you installed with the install script (`curl -fsSL https://kli.kleisli.io | sh`). If you installed through Nix, update through Nix instead; see [Refuses a Nix-managed binary](#refuses-a-nix-managed-binary). ## Update to the latest release Run: ```sh kli update ``` kli queries GitHub for the latest release tag, compares it to the running version, and shows what it found: ``` current: v0.3.1 new: v0.4.0 Proceed? [Y/n] ``` Press Enter or `y` to continue. An empty line counts as yes. When the update finishes, kli prints: ``` kli updated to v0.4.0. ``` If you are already on the latest release, kli prints `Already up to date (v0.4.0).` for that version and exits without downloading anything. If you decline the prompt, it prints `Update cancelled.` ## Update without the prompt Pass `--yes` (or `-y`) to skip the confirmation. This is the form to use in a script or a `cron` job: ```sh kli update --yes ``` The version check still runs, so an up-to-date binary stays untouched. ## Install a specific version Pass `--version` with a release tag to install that release instead of the latest: ```sh kli update --version v0.3.0 ``` The tag must match a published release on [github.com/kleisli-io/kli](https://github.com/kleisli-io/kli); kli validates it against the releases endpoint before downloading and fails if no such release exists. This works for both upgrades and downgrades. Combine it with `--yes` to install a pinned version unattended: ```sh kli update --version v0.3.0 --yes ``` ## What an update does `kli update` keeps your current install in a `.bak` sibling directory while it activates the new release, so a failure rolls back to it: 1. It downloads the release archive for your platform and checks it against the release `checksums.txt`. A mismatch aborts the update with the bytes left untouched. 2. It moves your current install aside to a `.bak` directory and moves the new files into place. 3. It runs the freshly installed binary's `version` command. On success, it deletes the `.bak` backup. 4. If the new binary does not run, kli deletes the broken install, renames the `.bak` backup back into place, and reports `the updated binary failed to run; rolled back`. You keep a working kli whether the update succeeds or fails. There is no half-installed state to clean up. ## Refuses a Nix-managed binary A kli installed through Nix lives under a read-only store path, and the package, not the updater, owns that file. `kli update` refuses such a binary and prints: ``` kli is managed by Nix; update through your Nix configuration instead. ``` Bump the version in your Nix configuration and rebuild to update a Nix-managed kli. See [Installation](/kli/cli/installation) for the install methods and which one you have. #### Log Observability Events kli emits an event for everything that happens in a session: model changes, session branches and resets, committed patches, notifications, and faults. This guide turns those events into a durable record. You set one path, kli appends each event to that file as a JSON line, and `/observability` tells you the sink is live. The sink is off until you give it a path. Nothing is recorded by default. ## Point the sink at a file Add an `observability` section to your settings and set `path` to where the log should be written. Set it in `settings.json` (global `~/.config/kli/settings.json`, project `/.kli/settings.json`; project wins) — see [Settings](/kli/config/settings). ```json { "observability": { "path": "~/.local/state/kli/events.jsonl" } } ``` A leading `~` expands against your home directory. A relative path resolves against the working directory kli was started in. An absolute path is used as-is. kli creates the parent directories on the first write, so the file does not need to exist beforehand. Settings load at startup. Start kli in your project, or restart it if it is already running, for the new path to take effect. The sink stays disabled when `path` is absent or an empty string. That is the off switch: remove the key (or set it to `""`) to stop recording without deleting the rest of your `observability` section. ## Record only some events By default the sink records every event. Add an `events` array to record only the types you care about. Each entry is a prefix matched against the event type name, so one entry can select a whole family. ```json { "observability": { "path": "~/.local/state/kli/events.jsonl", "events": ["session", "model-change", "fault"] } } ``` This records every type whose name starts with `session` (`session-switch`, `session-branch`, `session-reset`, `session-rewind`, `session-cleared`, and the rest), plus `model-change` and `fault`. An event is recorded when any prefix in the array matches; an event matching none is dropped. Matching is case-insensitive and prefix-based, so `session` catches the whole session family while `session-reset` catches just that one type. An empty array records every event, the same as omitting the key. ## Check the sink Run `/observability` in a session to see the current state. When the sink is enabled, it reports the resolved path, the active filter, the number of events written so far, and the last write error if one occurred: ``` observability: enabled path: /home/you/.local/state/kli/events.jsonl filter: session, model-change, fault events-written: 42 ``` The filter line reads `(all)` when no `events` array is set. When the sink is off, the command reports `observability: disabled`. Use this to confirm your `path` resolved to where you expected and that events are reaching the file. ## Read the log kli appends one event per line. Each line is a JSON object with four fields: - `timestamp` — the wall-clock time the event occurred, as a Lisp universal time integer (seconds since 1900-01-01 UTC). - `type` — the event type name, downcased (for example `model-change`, `session-branch`, `fault`). - `source` — the originator of the event, present only when the event carries one. - `payload` — the event's data, present only when the event carries one. Keyword-keyed data renders as a JSON object with downcased keys; other data renders as an array or a string. A `fault` event, for instance, looks like this: ```json {"timestamp":3958372800,"type":"fault","source":"tool-call","payload":{"seam":"tool-call","unit":"read-file","condition":"file not found","dispatch":"contained"}} ``` Each line is independent JSON, so pipe the file through `jq`, `grep`, or any line-oriented tool. Each write is appended under a lock and flushed, so a `tail -f` of a running session never shows a half-written line. A failed write does not interrupt the session. The sink records the error, drops that one line, and keeps running; the error then shows up on the `last-error` line of `/observability`. ## Related - [Configure kli with settings.json](/kli/config/settings) - [The Agent Loop](/kli/concepts/the-agent-loop) ### Concepts #### The Live Image You can add a tool, change a command, swap a model provider, or remove a piece of behavior without restarting kli. The running session keeps going: your context, your session log, and the model connection all survive the change. That follows from how kli is built, which is what this page explains. ## A small kernel, everything else an extension kli is one running program. At its center is a small kernel: a registry of live objects and a single protocol that knows three operations — install a protocol, switch to it, and roll back to the previous one. That is nearly all the kernel does. It holds no model provider, no tool, no command, no rendering code. Everything you interact with is an extension installed on top of the kernel. The model providers (anthropic, openai, openai-codex, and compatible), the tools that read and edit your files, the session log, the slash commands, and the terminal interface are all extensions. They were installed at startup the same way you would install one yourself, and they can be removed the same way. An extension is a value before it is an effect. It declares what it requires and what it provides, and installing it runs through one step that records every piece it added to the protocol. Each kind of contribution has a matching retractor: a tool, a capability provider, a generic-function method, and a raw effect each install and drain through their own paired operations. Because installing was recorded, removing is possible, and removing an extension drains exactly the pieces it installed and nothing else. A slash command is one such extension: it registers through a capability provider and retracts with it. ## What "while it runs" means kli runs inside a live image. The image is a running Lisp process that holds the program and its state in memory at once, and that program can be redefined while it is running. This is the mechanism. It is why the kernel can install, retract, and replace extensions in a live session instead of regenerating a binary and starting over. Replacing an extension is its own operation. The kernel deactivates the old version, draining its contributions, then activates the new source. If the new version fails to come up, the kernel reactivates the old one and reports the error, so a botched change leaves you where you started rather than in a broken state. The same holds for switching the whole protocol: a switch validates and smoke-tests the candidate first, and any failure rolls back to what was active before. ## What this lets you do The arrangement turns "change kli" from a build-and-restart cycle into an in-session action. - **Add behavior.** A new tool or command is an extension; installing it makes it available in the current session. - **Change behavior.** Redefining an extension's source replaces the running version, with automatic fallback to the previous version if the new one fails. - **Remove behavior.** Deactivating an extension retracts its tools, commands, providers, and methods together, leaving the rest of the session untouched. - **Recover.** Because every change is a recorded transaction, a failed switch or a failed recode returns the session to its prior working state instead of killing it. You can also pull an extension from outside your machine. The in-session `/install ` command fetches a remote extension, shows a trust card describing what it is, verifies it against the git tree sha1 you pinned, and installs it only after you confirm. That consent step is separate from installing the kli application itself, which is the one-time `curl -fsSL https://kli.kleisli.io | sh`. ## Why it is built this way Making the kernel small and everything else a retractable extension lets you bend kli to a project — a tool that knows your build, a provider pointed at an endpoint you run — in the session that needed it, with no source edit or rebuild. The kernel stays small on purpose. The fewer things it does, the fewer things can break when an extension is added or removed, and the more of kli can be treated as replaceable parts rather than fixed structure. The model providers themselves follow this rule — each one is an extension that installs a provider and retracts it cleanly, interchangeable parts rather than one wired-in default. ## Related - [Profiles](/kli/concepts/profiles) — how groups of extensions are bundled and selected. - [Permissions and capabilities](/kli/concepts/capabilities-and-fault-barriers) — the capability set that gates installing, retracting, and recoding behavior. - [Connect a provider](/kli/guides/connect-a-provider) — installing and configuring a model provider extension. - [Installation](/kli/cli/installation) — installing the kli application. #### Extensions All the Way Down kli runs inside its own live SBCL image. The boot kernel knows only how to install, switch, and roll back protocols. The extension system is itself a protocol installed on that kernel, and every contribution carries a retractor, so retraction drains exactly what it added. Extensions all the way down. That sentence is the whole design. The kernel is small on purpose (see [The live image](/kli/concepts/the-live-image)); everything you interact with is a contribution installed against a protocol, paired with a retractor that undoes it. The kind vocabulary the contributions are written in is contributed the same way, which is where "all the way down" stops being a slogan (see [Defining a contribution kind](/kli/extend/lisp-extensions/defining-a-contribution-kind)). The arrangement buys three things. Each is the same install/retract mechanism seen at a different reach into the running program. ## Specialize the running program. A `:method` contribution adds a method to any generic function in the live image; its retractor is `remove-method`. You change the program's own dispatch and roll it back, with no rebuild. The kind compiler turns the clause into a `make-method-contribution` carrying the generic-function name, qualifiers, specializers, and body; install adds the method, retract removes that exact method. This is the mechanism at its sharpest: the unit you install and drain is a method on kli's own code. ## Rewrite without restarting, keep the state. A hot patch swaps a function's code while keeping its closed-over state, so the session keeps its buffers and its scrollback across the change. cairn does this in tree: its context effect calls `recode-context-transform-policy` to splice live task context into every turn, saving the previous `extra-messages-fn` and handing its retractor the saved value to restore. The recode is reversible because the contribution recorded what it replaced, not merely what it added. It runs behind the capability and fault-barrier machinery (see [Capabilities and fault barriers](/kli/concepts/capabilities-and-fault-barriers)), so a hot-patched function that throws degrades rather than killing the session. ## Switch the whole world, safely. A protocol switch validates, smoke-tests, swaps, and rolls back on any error. This sits at the kernel altitude, not the extension-authoring one. kli's control plane exposes `control-install-protocol`, `control-switch-protocol`, and `control-rollback-protocol`, each gated on its own capability (`:protocol/create`, `:protocol/switch`, `:protocol/rollback`). An extension does not switch the world; the kernel does. What an extension supplies is the retractor that makes a switch clean: because every contribution drains exactly what it added, the kernel can tear down one protocol and stand up another with no residue. The safety of the switch is the reversibility of the pillars beneath it, used by the kernel. ## Where this goes The pillars are the *why*. The mechanics of writing and installing the contributions behind them are the *how*: - [Lisp extension anatomy](/kli/extend/lisp-extensions/anatomy) — `defextension`, the manifest as a value, install as a recorded transaction. - [Defining a contribution kind](/kli/extend/lisp-extensions/defining-a-contribution-kind) — how the kernel defines `:method`, `:effect`, and the rest, and how you add a kind. - [Recoding live](/kli/extend/lisp-extensions/recoding-live) — the hot-patch path behind the second pillar. - [The live image](/kli/concepts/the-live-image) — the running-image mechanism all three pillars rest on. #### The Agent Loop A turn is one model call plus the tool calls it makes; kli runs turns back to back until the model replies without asking for a tool. That is the agent loop: each turn ends by deciding, from the model's own reply, whether to run another. It is why a chain of reads, runs, and edits is not one model call, and why a tool that fails rarely stops the whole task. ## A turn, step by step A turn is the unit kli repeats. Each turn runs five steps in order. **Seal the context projection.** Before kli calls the model, it takes an immutable snapshot of the conversation so far. The live session can change while a request is in flight; the snapshot cannot. The model always sees a fixed view of the messages, frozen at the moment the request went out. The turn can also carry ephemeral context that rides this one snapshot and is never written back, so per-turn information reaches the model without becoming part of the permanent log. **Stream the model response.** kli sends the sealed snapshot and reads the reply as it arrives. Text and reasoning come back incrementally. When the stream ends, kli has the full assistant message: prose, and zero or more tool calls the model wants to make. **Execute the tool calls serially.** Tool calls run one at a time, in order, not in parallel. After each call kli checks two things: whether you asked it to stop, and whether you typed a new instruction while it worked. Serial execution keeps the order of effects predictable, so a write that a later call depends on has already happened. **Append to the session log.** The assistant message goes into the log, and so does each tool's result. The log is the conversation: it is what the next snapshot is built from. By the time the turn ends, the model's request and every answer to it are recorded together. **Loop until there are no tool calls.** This is the decision that makes kli autonomous. If the response asked for at least one tool call, kli runs another turn so the model can react to the results it just got. If the response asked for none, the model is done, and the loop stops and goes idle. The model controls its own length: it keeps the loop alive by calling tools, and ends it by answering in plain text. ## Why the loop, and not a single call A single model call can plan an edit, but it cannot see whether the edit worked. The loop closes that gap. The model calls a tool, the result lands in the log, and the next turn shows the model what actually happened. A failed test becomes input, not a dead end. This is why kli can run a build, read the real error, and fix the line that caused it: each turn feeds the last turn's results back in. The snapshot-then-stream order matters here. Because the context is sealed before the call, anything that happens during the call — a tool result appended, an edit you make on the side — does not change the request already in flight. It lands in the log and enters the *next* snapshot instead. Every request the model sees is internally consistent, and new information always arrives at a turn boundary rather than mid-thought. ## Why a failed tool comes back as a result When a tool fails, kli does not abort the turn. It turns the failure into a tool result marked as an error and appends it to the log like any other result. The next turn shows that error to the model, which can read it and try something else. There is a hard reason this is the only safe choice. The model's request to call a tool is already in the log before the tool runs. A tool call with no answer leaves the conversation in a broken shape that model providers reject: once a tool call is recorded, the request that follows it must contain that call's result, or the provider refuses every further turn over that conversation. So an exception escaping a tool would not just lose one result — it would wedge the whole session. kli closes that by guaranteeing every tool call gets a result, even when the tool throws, even when the model invents a tool that does not exist. The error is recorded as the result, and the loop continues. The same rule covers the case where the model calls a tool with arguments that are not valid JSON. Rather than invoke the tool with empty arguments and let it fail as a missing-parameter error — which would mislead the model about what went wrong — kli returns a result that names the real cause: the arguments did not parse. The model gets an accurate error and can correct it on the next turn. The model wrote the tool call, so the model is the party that can fix it. By feeding errors back as results instead of unwinding the turn, kli lets the model debug its own work the same way it does anything else: read what happened, decide what to do next. ## What stops a loop Three things end a run. The model can stop it by returning a turn with no tool calls — the normal finish. You can stop it: if you ask kli to abort, the in-flight request is cancelled and the agent settles without starting another turn. And a turn that hits an error the loop cannot turn into a tool result (a failure in the model call itself, not in a tool) ends the run in an error state, where a higher layer can retry it or surface it to you. A failed tool is none of these. It is data the model gets to act on. ## Related - [Sessions as a Tree](/kli/concepts/sessions-as-a-tree) — the append-only record each snapshot is built from - [Steer a Running Turn](/kli/guides/steer-a-running-turn) — typing to kli while a turn is running - [Capabilities and Fault Barriers](/kli/concepts/capabilities-and-fault-barriers) — how a tool call can be denied, which also comes back as a result #### Sessions as a Tree A kli session is an append-only tree of entries, each one pointing at its parent. ## What a session is A session is an append-only collection of entries. Once an entry is written, it is never edited and never removed. Each entry carries the id of its parent, the entry that came before it on the same line of conversation. Follow those parent links from any entry back toward the start and you trace a single path; that path is a *branch*. The session also tracks one entry as its *leaf*: the current tip, the entry the next one will attach to. So "the conversation right now" is not the whole table of entries. It is the branch from the leaf back to the root, read in order. Everything kli sends to the model on a turn is built by walking that branch. Entries that are not on it are still in the table; they just are not part of what the model sees. Entries are typed, and the type says what the entry is. A message entry holds one message: your prompt, the assistant's reply, or a tool result. A model-change entry records that you switched providers or models mid-conversation. An option-change entry records a change to a semantic model option such as `reasoning-effort`. A compaction entry holds a summary that stands in for older history. A branch-summary entry records what happened on a path you left. Walking a branch and keeping only the entries that enter model context is how kli turns the tree back into a prompt. ## Why a tree, and not a list A flat list can only grow at the end. To go back, it would have to delete what came after, and then the alternative you abandoned is gone. A tree never deletes. Going back means pointing the leaf at an earlier entry; the entries past it stay in the table, off the current branch but intact. That single property is what makes the next three behaviors possible, and it is why each of them leaves the path you came from intact. ### Rewind When you rewind, kli steps the conversation back to before one of your earlier prompts. It does this by branching at that prompt's parent and moving the session onto the new branch. The prompts you can rewind past are the user prompts on the current branch, newest first; the rewind menu lists them by what you typed. The original branch is untouched, so the path you rewound away from is still there to return to. (Rewinding your very first prompt has no parent to branch at, so it starts a fresh session instead.) ### Branches A branch shares a prefix and then diverges. When kli branches a session at an entry, it makes a new session whose entries are the chain from that entry back to the root, with the leaf set to the chosen entry. The two sessions now share every entry up to the fork and own their continuations separately. Rewind is built on exactly this: it is a branch at a chosen prompt's parent. The shared prefix is the same entries, not a copy of them, however long the shared history is. ### Compaction summaries as nodes A long conversation eventually carries more history than is useful to resend on every turn. Compaction handles this without throwing anything away. kli picks a cut point in the branch, summarizes everything older than it, and appends a *compaction entry* holding that summary together with the id of the first entry it kept. The cut snaps forward to a prompt or reply so the kept window never begins on a tool result with no call before it. The summary is a node like any other, on the branch, with a parent. When kli builds the messages for the next turn and finds a compaction entry on the branch, it sends the summary in place of everything before the cut and then the kept entries after it. The older entries are not deleted; they are simply no longer on the path the model reads. A later rewind or branch can still reach them. Compaction is also why kli keeps a token estimate per entry: it sums the recent entries to decide where the cut should fall, keeping roughly the last stretch of conversation and summarizing the rest. ## One readable record per line Every entry serializes to a single self-describing record. An entry's record names its type and lists its fields by name: its id, its parent's id, a timestamp, and the type-specific contents. The parent link is right there in the record, so the tree is reconstructed by reading the records and following the ids. Records are read back with evaluation disabled, so loading a session never runs code, and message content that contains newlines round-trips because a record is read as one form, not split on line breaks. ## Persistence is opt-in By default a session lives only in memory. Start kli, work, quit, and the tree is gone when the process ends. Nothing is written to disk unless you ask for it. You opt in with a session directory. Point the `sessionDir` setting at a folder and kli swaps the in-memory store for one backed by files. Each session becomes a file: a versioned header, then one entry record per line, in the order the entries were appended. A new entry is appended to the file as it is written, so the file grows with the conversation rather than being rewritten each time. A crash mid-write can leave a torn record at the end of the file; on reload kli drops that trailing fragment and keeps everything before it, so an interrupted write costs you at most the last entry. With a session directory configured, past sessions show up under `/resume`, where the branch structure is rendered as a forest so you can see which conversations forked from which. The in-memory store and the file store are the same tree behind the same operations. Persistence changes where the entries live, not what they are or how branches, rewind, and compaction work over them. ## Related - [The Agent Loop](/kli/concepts/the-agent-loop) — the turn that appends to the tree and reads the current branch - [Context and the System Prompt](/kli/concepts/context-and-the-system-prompt) — how a branch becomes the messages the model sees - [Configuration](/kli/config) — where `sessionDir` and other settings live #### Context and the System Prompt kli assembles the system prompt from per-directory context files (`AGENTS.md`, `CLAUDE.md`) and two override files, rebuilt on each turn — so what the model knows is what you edit into those files, not what you re-explain in chat. The system prompt is the standing instruction block at the top of every request, the part the model reads before your message. kli assembles it from three layers: a base prompt it ships with, your project context files, and two override files. This page is about where each layer comes from and how they combine. ## The base prompt kli builds a base system prompt for every session. It states the agent's identity (you are kli, an interactive coding assistant in a terminal), lists the tools currently registered on the running session, carries a few tool-agnostic guidelines, and reports the current date and working directory as authoritative environment facts. The tool list is read live from the session, so a tool added by an extension appears in the prompt without a restart. This base is the starting point. The other layers extend it or replace it. ## Project context files kli discovers per-directory context files and renders them into a `# Project Context` section appended to the system prompt. Each discovered file appears under a `## ` heading followed by its contents, so the model sees both the instructions and where they came from. In any one directory, kli looks for these names in order and takes the first that exists: 1. `AGENTS.md` 2. `AGENTS.MD` 3. `CLAUDE.md` 4. `CLAUDE.MD` One file wins per directory. An `AGENTS.md` next to a `CLAUDE.md` means the `CLAUDE.md` is not read for that directory. ### The discovery walk Discovery starts at the working directory kli is running in and walks up the directory tree, stopping at the repository root. A directory holding `.git` (whether a directory or a worktree file) is the root, and the walk includes it. Outside a git repository, the walk continues up the full ancestor chain. The walk gives you layered instructions. A context file at the repository root states project-wide conventions; a context file in a subpackage states conventions for that subpackage; both reach the model when you work inside the subpackage. kli orders the rendered section outermost first, so the root file comes before the deeper one, and the closest file is read last. A file that the global config directory contributes (`~/.config/kli/`) comes first of all, ahead of the repository chain. Files are deduplicated by path, so a single file reached two ways appears once. ## Overriding the system prompt Two files change the base prompt directly rather than adding a section. `SYSTEM.md` replaces the base prompt entirely. When kli finds it, the shipped identity-and-tools prompt is dropped and your file's contents take its place. The project context section still renders after it, so `AGENTS.md` and `CLAUDE.md` instructions remain in effect under a custom base. `APPEND_SYSTEM.md` appends to the prompt. Its contents are added after the base (or after your `SYSTEM.md`, when both are present) and before the project context section. Use it to add standing instructions without discarding the identity and live tool list the base prompt carries. kli reads these two override files from the project config directory `/.kli/` first, then the global config directory `~/.config/kli/`, and takes the first that has content. A blank or whitespace-only file is treated as absent, so an empty `SYSTEM.md` does not silently erase the base prompt. The assembled order, top to bottom, is: 1. the base prompt, or your `SYSTEM.md` in full when present 2. `APPEND_SYSTEM.md` 3. the `# Project Context` section from `AGENTS.md` / `CLAUDE.md` ## The 2 MiB cap kli reads a context or override file only when it is at most 2 MiB. A larger file is treated as absent: not truncated, not partially read, just skipped, and for context files the next candidate name in the directory is tried. The cap is a guard against a pathological file, a checked-in binary or a runaway log that happens to match a candidate name, not a budget on how much you can write. It sits at or beyond what a model can ingest in its context window, so a real instruction file does not approach it. ## How it stays current The system prompt is rebuilt from these files on each submission, not cached at startup. Editing an `AGENTS.md` or a `SYSTEM.md` while kli is running changes what the model reads on your next message; there is nothing to reload. Each rebuild first removes the append block and project context section it composed last time, then composes fresh ones, so neither stacks up across turns. ## Related - [The agent loop](/kli/concepts/the-agent-loop) — how the assembled prompt and your message become a model request - [Files and paths](/kli/config/files-and-paths) — what lives under `/.kli/` and `~/.config/kli/` - [Settings reference](/kli/config/settings) — the keys that configure a session #### Tools and Hashline Edits kli ties every edit to the exact lines the model last read. If those lines no longer match what is on disk, the edit is refused and nothing is written. This page explains how that works and why it is the default. ## What a read returns When kli reads a file, it does not hand the model raw text. Each line comes back prefixed with an anchor: the line number, a short content hash, and the line itself, in the form `LINE:HH|content`. A read of a three-line file looks like this: ``` 1:a3|def greet(name): 2:7f| return f"hello {name}" 3:00| ``` The number is the line's position. The `HH` is two lowercase hex digits, a fold of an FNV hash over that line's raw text. It is short on purpose: it is a fingerprint of the line's content, not a checksum you read. Two lines with the same text get the same hash; change one character and the hash changes. The model uses these anchors to refer to lines when it edits. An anchor names both where a line is and what it contained at read time. ## What an edit is An edit in kli is a patch made of operations against anchors, not a new copy of the file. The patch groups operations under a file path and addresses lines by anchor. To replace lines 1 through 2 of the file above, the patch carries the anchors `1:a3` and `2:7f` together with the replacement text. To insert after a line, it names that one line's anchor. To delete a range, it names the start and end anchors. Every anchor in the patch repeats the hash the model saw. That repetition is the safety mechanism, and it is the reason an edit is small. The model sends only the lines it wants to touch, each one carrying proof of which version it is touching. ## How an edit is validated Before kli writes anything, it checks the patch against the file as it exists on disk right now. Two checks run, in order. First, read-before-edit. kli keeps a per-file record of what the model last saw, keyed by the file's resolved path. If a patch names a file the model has not read in this session, the patch is rejected with a message telling the model to read the file first. The model cannot edit a file it has never seen. Second, anchor re-hashing. For every file the patch touches, kli reads the current contents from disk, splits them into lines, and re-computes each line's hash. For each anchor in the patch, it checks two things: that the anchor's line number is in range, and that the anchor's hash equals the freshly computed hash of the line now at that position. If the line number points past the end of the file, the anchor is rejected. If the hash does not match, the anchor is stale and is rejected. The rejection message names the line and tells the model to re-read around it and resend. The hash check compares against disk, not against the cached record of what the model saw. The cache only answers "did the model read this file." It cannot answer "is this anchor still good," because the file may have changed on disk since the read, from a build step, a formatter, another tool, or you. Only re-hashing the live file can catch that. ## Why a stale anchor rejects the whole patch Validation is all-or-nothing per call. kli gathers every problem across every file in the patch first. If there is even one problem, it raises an error with all the problems listed and writes nothing. Files are written only after the entire patch validates clean. This is the difference between an anchor-validated patch and a blind overwrite. A blind overwrite takes the new contents and replaces the file, whatever the file now holds. If the file changed under it, the overwrite erases that change without noticing. The damage is silent: the write succeeds, the file looks edited, and the lost change surfaces later as a confusing regression. An anchored patch cannot do that. The hashes pin the edit to a specific version of each line. If line 2 was `return f"hello {name}"` when the model read it but is now something else, the anchor `2:7f` no longer matches, the patch is rejected, and the file keeps its current contents. The model gets back a precise message: anchor `2:7f` is stale, re-read around line 2. It re-reads, gets fresh anchors, and resends a patch built against the current file. The conflict turns into a retry instead of a lost edit. The drift does not have to land on a line the patch edits. Because anchors carry line numbers and the file is re-split fresh, an insertion or deletion earlier in the file shifts every later line's number. An anchor that still has the right hash but the wrong line, or the right line number holding different text, fails the check. The patch only applies when its view of the file still holds. ## How this fits the rest of kli This anchored read-then-edit cycle is one tool family among several, all of which run with full permission by default; kli has no per-action approval prompt. The safety here is not a gate you click through. It is a property of the edit format: an edit that does not match the file it claims to edit cannot be applied. For how kli decides what a tool may touch at all, see [Capabilities and fault barriers](/kli/concepts/capabilities-and-fault-barriers). For what happens when a tool call comes back as a problem the model has to resolve, see [The agent loop](/kli/concepts/the-agent-loop). #### Capabilities and Fault Barriers Out of the box, kli reads, runs, and edits your code without stopping to ask. There is no "allow this action?" prompt to clear, no per-command gate to babysit. You start kli in a project and it works on your code the way you would: it opens files, runs the shell, and applies edits directly. When you do want a session held back from some of that, you say so once, in writing, before the session starts. And when a piece of kli misbehaves, the failure is confined to that piece instead of taking down the program you are working in. Two mechanisms produce that behavior: a capability subject that decides what a caller is allowed to do, and fault barriers that decide what happens when a caller breaks. ## Why there is no approval prompt Every gated operation in kli runs under a *subject* — the capability-bearing identity of whoever is calling. Before a tool reads a file, runs a process, edits the context, or installs an extension, it asks the current subject whether it holds the matching capability. If it does, the call proceeds. If it does not, the call is denied. The default subject is the *system subject*, and the system subject passes every check. It is the value in force at startup and for a normal boot. So under the default, every capability question gets the same answer — yes — which is why a fresh session does its work without ever interrupting you. There is no approval workflow because there is nothing to approve against: the default identity is already trusted with everything. This is a deliberate choice, not a missing feature. An interactive prompt trains you to click "yes" without reading it, and a tool that asks before every file write is a tool you stop trusting to do real work. kli's position is that the decision about what an agent may touch belongs to you, made once, ahead of time, rather than to a dialog box in the middle of a task. ## Restricting a session with capabilities You narrow what a session can do through the `capabilities` key in settings — `~/.config/kli/settings.json` for your global default, or `/.kli/settings.json` for one project. The key is an array of capability names, and its presence is what switches a session from "trusted with everything" to "trusted with exactly this list." The three states are distinct: - **Key absent.** The session keeps the system subject. Every gated tool is allowed. This is the default. - **Key present, non-empty.** The session runs under a restricted subject that holds exactly the capabilities you named, and nothing else. A tool whose capability is not on the list is denied when it runs. - **Key present, empty array (`[]`).** The session holds no capabilities at all. Every gated tool is denied. This is the most restrictive setting and the one to reach for when you want a read-nothing, run-nothing session. A capability name implies the finer-grained capabilities it depends on. Granting `tools/standard`, for instance, grants the file read, file write, file edit, and process-execution capabilities that the standard toolset is built from — you do not have to enumerate each one. The set you write is closed under these implications before the session uses it, so naming a coarse capability is enough to admit everything it covers. A malformed value — anything that is not an array of strings — is not treated as "deny all." kli warns that the setting was ignored and falls back to the system subject. The restriction has to be a well-formed list to take effect; a typo does not silently lock you out, and it does not silently grant you nothing either. (The warning surfaces in the transcript as a boot diagnostic, since the terminal takeover would otherwise wipe it.) For the capability names you can put in the array and the tools each one gates, see [Capabilities](/kli/config/capabilities). ## How a fault stays contained kli is one running program with everything else installed as a retractable extension — the model providers, the tools, the commands, the terminal UI. That design lets you change kli while it runs, and it also means a fault in one extension is, structurally, a fault inside the program you are using. Without containment, an extension that threw an error mid-render could crash the whole session and lose your work. Crash barriers are what keep that from happening. A barrier wraps a *seam* — a place where extension or hot-patched code runs — and catches errors that escape it. (It catches ordinary errors only; it never swallows the serious conditions that signal the process itself is unsound.) When a fault crosses a barrier, three things can happen, and each barrier picks its policy for the seam it guards: - **continue** — unwind the failed unit and return a fallback value. The fault is recorded; the session carries on as if that unit produced nothing. - **reify** — do everything `continue` does, and additionally surface the fault to you, typically as an event in the transcript, so you see that something failed and where. - **escalate** — decline to contain. The error keeps propagating outward, toward the next barrier or the process boundary. This is how a fault that should be loud stays loud. Every contained fault is written to a per-seam log under the cache directory regardless of policy, so a failure is never silently lost even when the session continues. The diagnostic goes to a file, never to the terminal, because writing to the terminal would corrupt the live UI that the barrier is trying to protect — and a failure in the logging path itself loses the line and nothing else. Containment is the one job a barrier may not break, so reporting a fault can never cause one. The result you feel is graceful degradation. A widget in the status bar that errors contributes no line that frame instead of breaking the bar. A render that faults skips the frame and tells you in the transcript. A misbehaving extension fails in place and the rest of the session keeps going. Degradation is not unconditional, though. A seam that keeps faulting is a seam that is genuinely broken, and continuing to paper over it just hides the problem. The render barrier, for example, runs `continue` until its faults pile into a streak, then switches to `escalate` so a persistently broken renderer surfaces as a real failure rather than an endless run of skipped frames. The point of a barrier is to keep one fault from killing the app, not to pretend a fault never happened. ## How the two relate Capabilities decide what a caller is *allowed* to do; barriers decide what happens when a caller *fails*. They are independent, and together they set the shape of a kli session: trusted by default and held back only on your explicit instruction, and able to survive the failure of any single part because no single part can take the whole down. To put a restriction in place, see [Restrict what kli can do](/kli/guides/restrict-what-kli-can-do). For the live-kernel design that makes extensions — and therefore seams — the unit of both trust and containment, see [The live image](/kli/concepts/the-live-image). Capabilities bound what an agent is *allowed* to do, not what the kli process can *reach* on the host; for that boundary, see [Security model and sandboxing](/kli/concepts/security-model-and-sandboxing). #### Security Model and Sandboxing kli draws a hard line between two questions that are easy to confuse. *What is an agent allowed to do?* is the authority question, and kli answers it precisely through the capability lattice. *What can the kli process touch on this machine?* is the containment question, and kli does not answer it at all. The process runs with your privileges, and so does everything it does on your behalf. > kli enforces **authority** — which capabilities an agent may exercise — not > **containment**. By default nothing kli runs is isolated from the host: a > granted shell command, file write, or `eval` acts with the full privileges of > the kli process. For autonomous or untrusted use, run kli inside your own > confinement (bwrap, a container, or a VM). The sandbox is the boundary; kli is > not. ## Authority is not containment Authority is decidable and kli enforces it. Every gated tool asks the current subject whether it holds the matching capability before it acts, and a capability the subject lacks is denied. You shape that authority up front through the `capabilities` array — see [Capabilities and fault barriers](/kli/concepts/capabilities-and-fault-barriers) for how the subject decides, [Restrict what kli can do](/kli/guides/restrict-what-kli-can-do) for the steps, and [Capabilities](/kli/config/capabilities) for the full vocabulary. Containment is a different mechanism living in a different place: the operating system, not kli. A capability decides whether the `bash` tool may run a command; it does not and cannot decide what that command, once running, may read or write on disk or send over the network. A shell granted `process/exec` runs `curl`, `make`, and `rm` with the full reach of the kli process. Narrowing the capability set reduces which *tools* the agent can reach; it never shrinks the *blast radius* of the ones it can. That second job belongs to a sandbox you put around the whole process. ## Why the boundary is the process, not the tool It is tempting to want a sandbox bolted onto the `bash` tool alone — confine shell-outs and leave the rest. That boundary is theatre. The `eval` tool runs Common Lisp inside the live image, and the file tools (`read`, `write`, `edit`) act on the host filesystem directly; both wield the process's full authority without ever spawning a subprocess. A wrapper around `bash` would confine shell-outs while `write` and `eval` kept unrestricted host access — a boundary with a hole exactly where it matters. The one boundary that contains every tool at once is the process boundary. A jail around the kli process contains `bash`, `eval`, and the file tools together, because all three draw on the same process privileges. So kli builds no per-tool sandbox and instead makes whole-process confinement easy to stand up around it. ## See the authority a run will hold Before you size a sandbox, see exactly what a run could do inside it. `kli --print-authority` resolves the subject a session would hold — the configured capabilities under the resolved profile — and prints its atoms and constraints, then exits without reading a prompt or running an agent: ```sh kli --print-authority ``` It defaults to the same profile `-p` uses; `--profile ` inspects another. The headless attenuation flags apply here too, so you can preview a narrowed run: `--read-only` drops `file/write`, `file/edit`, and `process/exec`; `--no-bash` drops `process/exec`. Add `--json` for one machine-readable object: ```sh kli --print-authority --read-only --json ``` The report tells you whether the run is universal (every capability), bounded to a listed set, or holds nothing — the information you need to decide how tight the surrounding confinement must be. ## Confine the kli process The recipes below all do the same thing: bind the working directory writable, mount the rest of the filesystem read-only, drop into isolated namespaces, and run kli inside. They differ only in the mechanism your platform already has. ### The Nix sandbox option If you build kli through the flake's producer, confinement is one option. It wraps a fixed store entrypoint, so the wrapper lives *outside* the binary it confines — nothing the model or a repo-local setting can switch off: ```nix programs.kli = { enable = true; sandbox = { network = true; # set false to unshare the network namespace writablePaths = [ "${config.home.homeDirectory}/.cache/kli" ]; denyRead = [ "${config.home.homeDirectory}/.aws" ]; denyEnv = [ "AWS_SECRET_ACCESS_KEY" ]; }; }; ``` The same `sandbox` set is accepted by `mkConfiguredKli` in a dev shell and by the NixOS module. The wrapper binds `$PWD` writable and `--chdir`s into it at run time, `--ro-bind`s `/` for everything else, mounts a private `/dev`, `/proc`, and `/tmp`, and sets `--unshare-pid --unshare-ipc` (bwrap drops the ability to gain new privileges on its own — there is no `--no-new-privs` flag). `writablePaths` adds extra writable binds; `network = false` adds `--unshare-net`; `denyRead` masks paths and `denyEnv` unsets environment variables (both below). ### bwrap The same confinement by hand, for any install: ```sh bwrap \ --ro-bind / / \ --dev /dev --proc /proc --tmpfs /tmp \ --unshare-pid --unshare-ipc \ --bind "$PWD" "$PWD" --chdir "$PWD" \ -- kli ``` Append `--unshare-net` to cut the network (read the network note below first). ### Docker or Podman Run kli from an image that has it installed, mounting only the project: ```sh docker run --rm -it \ -v "$PWD:/work" -w /work \ kli-image kli ``` The container is the filesystem boundary; nothing outside the mount is visible. Add `--network none` to cut the network. ### systemd-run Wrap a single transient unit with systemd's own sandboxing: ```sh systemd-run --user --pty \ -p ProtectSystem=strict \ -p ReadWritePaths="$PWD" \ kli ``` `ProtectSystem=strict` makes the filesystem read-only except `ReadWritePaths`; add `PrivateNetwork=yes` to cut the network. Leave the path holding kli's config and credentials readable, or the model API client cannot authenticate. ### A dev container A `.devcontainer` that runs kli inside the container makes the container the boundary for every session opened in it, with the container runtime governing what the workspace can reach. ## Hide secret files from the agent A capability cannot hide one file: `file/read` is all-or-nothing, so a session that can read the project can read a secret sitting in it. The place to hide a specific path is the mount namespace, where the mask covers `bash`, `read`, and `eval` uniformly because it is in the kernel, not a per-tool filter. With the Nix option, list the paths under `denyRead`. A file there reads as empty, a directory reads as empty: ```nix sandbox.denyRead = [ "${config.home.homeDirectory}/.aws" "${config.home.homeDirectory}/.config/gh" ]; ``` By hand, overlay the same masks after the read-only root — a file with `/dev/null`, a directory with a tmpfs. Launched from a repo root, this hides a project-local secret and your cloud credentials: ```sh bwrap \ --ro-bind / / \ --dev /dev --proc /proc --tmpfs /tmp \ --unshare-pid --unshare-ipc \ --bind "$PWD" "$PWD" --chdir "$PWD" \ --bind /dev/null "$PWD/.envrc.local" \ --tmpfs "$HOME/.aws" \ -- kli ``` This is the at-rest boundary: it takes the secret off *disk*. Its in-environment twin is `denyEnv`, which keeps named variables out of every tool's *environment* (next section). `denyRead` masks files; `denyEnv` unsets variables — both act at the process boundary, so both cover `bash`, the file tools, and `eval` at once. ## Keep secret variables out of the environment A secret often lives in the environment, not just on disk: a token exported into the shell that launched kli is inherited by every shell-out. kli does not filter the environment per tool — `eval` can read any variable through `posix-getenv`, and a shell command can read `/proc/self/environ`, so a bash-only scrub is theatre the same way a bash-only filesystem jail is. The place to drop a variable is the process boundary, where the unset covers `bash`, the file tools, and `eval` together. With the Nix option, name the variables under `denyEnv`. Each is unset before the confined process starts: ```nix sandbox.denyEnv = [ "AWS_SECRET_ACCESS_KEY" "GH_TOKEN" ]; ``` By hand, add an `--unsetenv` per variable to the same bwrap invocation: ```sh bwrap \ --ro-bind / / \ --dev /dev --proc /proc --tmpfs /tmp \ --unshare-pid --unshare-ipc \ --bind "$PWD" "$PWD" --chdir "$PWD" \ --unsetenv AWS_SECRET_ACCESS_KEY \ --unsetenv GH_TOKEN \ -- kli ``` `denyEnv` is a blocklist of names to remove, not an allowlist of names to keep: it shrinks the inherited environment by the secrets you name and leaves the rest intact, so command lookups and tool configuration still work. ## Network is all or nothing Whole-process confinement makes the network all-or-nothing, because kli's model API client shares the process with the tools. Cutting the network namespace cuts the API along with everything else, so net-off is usable only with a model that runs locally. There is no built-in per-destination filter: allowing the API while blocking exfiltration to elsewhere is a filtering proxy you run in front of kli, not a control kli provides. The headline protection of the easy sandbox is therefore filesystem and process isolation, not network policy. ## Residual risks A sandbox bounds the blast radius; it does not make autonomous execution safe. Name these and plan for them: - **Destruction inside the writable workspace.** Nothing distinguishes `rm -rf .` from legitimate work within the directory you bound writable. Version control and backups bound this; the sandbox does not. - **Exfiltration over an allowed channel.** With the network on, an agent can send data anywhere it can reach. A filtering proxy in front of kli bounds this; kli does not. - **Credential read-at-rest.** `file/read` is coarse, so any readable secret is readable. `denyRead` masks the paths you name; a broader mount or LSM policy covers the ones you forget. - **A self-sandbox would be widenable.** A confinement the binary applied to itself could be loosened by injecting config. Keeping the wrapper external — a fixed entrypoint around the binary — is why the Nix option cannot be switched off from inside a session. The throughline: kli is honest about being a non-provider of containment, tells you the exact authority a run will hold, and makes the real boundary — a jail around the whole process — easy to put in place. The sandbox is the boundary; kli is not. #### Profiles A profile is a named group of extensions kli boots with. ## A profile is a named group of extensions kli is a small kernel with everything else installed on top as an extension: the model providers, the tools, the commands, the terminal UI. A profile is the list that says which of those to install at boot, under one name. The list is built from groups of extension manifests. Three carry the substance: - A **baseline** group every profile installs. It holds the parts kli needs to be an agent at all: the event system, the session log, the config layer, the agent loop, and the file, search, and shell tools. - A **model-provider** group: the `anthropic`, `openai`, `openai-codex`, and `compatible` providers. A profile that talks to a model installs this; one that does not, leaves it out. - A **terminal-UI** group: the chat view, the input editor, markdown rendering, the slash commands, completion. Only a profile meant to be driven by a person at a terminal installs this. Every profile also carries a **nix-declared** group, spliced in right after the baseline. It is empty in plain kli and holds whatever a Nix-configured image declares at boot, so those extensions boot as baseline children. The [profiles reference](/kli/config/profiles) lists exactly what each group installs. A profile names the groups it wants and they install together as a unit. A profile is nothing more than that explicit list, which is why the built-ins differ only in which groups they include. The kernel itself never learns what a profile is; it sees the install requests and nothing more. For why installing and retracting extensions on a running kernel works at all, see [The Live Image](/kli/concepts/the-live-image). ## The four built-in profiles Each built-in is a fixed combination of those groups, plus a declaration of any capabilities the profile expects you to supply. - **`interactive-terminal`** installs the baseline, model-provider, and terminal-UI groups. This is the default — what you get when you run kli with no profile selected. It is the profile for sitting at a terminal and working with the agent. - **`headless`** installs the baseline group only. No model providers, no terminal UI. It is the minimal agent core, the starting point for a profile or an embedding that wires its own providers and front end. - **`human-in-loop`** installs the same groups as `interactive-terminal` and declares an `approval` seam — a named point a human-approval extension is meant to fill. The profile itself does not provide approval; it states that the slot exists so an extension can complete it. - **`autonomous`** installs the baseline and the model providers but not the terminal UI, and declares `planner`, `scheduler`, `watchdog`, and `recovery` seams. It is the shape for an agent that runs without a person watching: it can talk to a model and use tools, with the supervision pieces left as seams for you to fill. A seam is a capability a profile declares but does not provide. It is how a profile names its own extension points without pretending to satisfy them. What fills a seam is a separate extension, governed by the [capabilities array](/kli/config/capabilities) and your installed extensions. ## How precedence picks the active profile At boot kli resolves one profile name from the first of these that is set: 1. The `--profile ` command-line flag. 2. The `KLI_PROFILE` environment variable. 3. The `profile` key in your merged settings, where a project's `/.kli/settings.json` wins over the global `~/.config/kli/settings.json`. 4. The default, `interactive-terminal`, when none of the above names a profile. The flag wins over the environment variable, which wins over settings, which wins over the default. If the resolved name is neither a built-in nor a profile declared in settings, kli warns and boots the default rather than failing. The warning surfaces in the session, so a typo in a profile name does not leave you guessing why you got the terminal you did not ask for. ## Profiles you define in settings Beyond the four built-ins you can declare your own profile under the `profiles` object in `settings.json`. A declared profile is a delta on top of a built-in, with four fields: - `extends` — the profile it builds on. With no `extends`, it bottoms out at `interactive-terminal`. - `enable` — extension ids to add to the active set. - `disable` — extension ids to remove from it. - `settings` — a settings overlay that rides along while the profile is active. Resolution walks the `extends` chain down to a built-in base, then folds the deltas from the base up: a later profile in the chain has the last word on any given extension id, and its settings merge over the earlier ones. A declared profile cannot reuse a built-in name; that is reserved, and an entry that tries to shadow one is ignored with a warning. A malformed entry is skipped the same way, so one bad profile does not stop the rest from loading. The `enable` and `disable` deltas gate your user extensions, the optional ones you install yourself. They do not gate the built-in groups, which arrive with the base. Whether a given user extension is installed at boot comes down to its own configuration and the active profile's deltas together. ## Switching profile while kli runs The `/profile` command lists the available profiles and live-switches between them. A switch re-bases your user extensions onto the target profile's set — installing the ones it wants that are absent, retracting the ones it does not — and swaps in the target's settings overlay. Because the switch operates on a running kernel, your context and session log carry across it. One thing does not switch live: the built-in base. The base group is installed once at boot, so switching to a profile with a different base (from `interactive-terminal` to `headless`, say) cannot take effect in place. kli tells you so and points you at restarting with `--profile `. Everything that is a delta on the same base switches in the running session. #### Models, Providers, and Transports kli does not bind a model to its wire protocol or its credentials. Three parts stay separate: a registry that lists what you can select, the providers that put entries in that list, and the transports that turn a selection into an HTTP call. That split is why you can run two providers side by side, switch between them mid-conversation, and add an OpenAI-compatible endpoint without touching the others. ## The registry: what you can select There is one model registry in a running kli. It holds two kinds of entries. A **provider** entry records how to reach a vendor: an API style, a base URL, header and metadata config, and which credentials it needs. A **model definition** entry records a single model you can pick, keyed by its provider and model id, with its context window and whether it reasons. The model definition also carries the API style, so picking a model is enough to know how its call will be shaped. The registry does not hold secrets and does not open sockets. It answers one question: given the credentials present right now, which models can you select? When you list models, the registry walks its definitions and keeps each one whose provider has a usable credential. A model whose key is not set never appears, so the list reflects what will actually run rather than the full catalogue. Selecting a model records a current selection on the registry and, if a session is active, appends a model-change entry to the session log. Selection is plain state, so switching providers mid-conversation is the same operation as picking a model at the start. ## Providers: extensions that fill the registry A provider is not built into the registry. It is an extension that, when it loads, registers its catalogue: an auth-provider, a credential reference, one provider entry, the model definitions, and the transport adapter for its API. The shipped providers: - **anthropic** — Claude models on the Messages API. Reads the key from `ANTHROPIC_API_KEY`. - **openai** — GPT models on the Responses API. Reads the key from `OPENAI_API_KEY`. - **openai-codex** — GPT models through a ChatGPT account on the Responses API. Authenticates by OAuth rather than an API key. - **compatible** — model entries you define for any OpenAI-compatible endpoint, declared in `~/.config/kli/providers.json`. Because every provider registers through the same path, the registry treats them uniformly: a Claude entry and a self-hosted entry are the same kind of object, differing only in their recorded API and credentials. Adding a provider adds rows to the list and one transport adapter; it changes nothing about the providers already there. The same uniformity runs in reverse. A provider is retractable: removing it drains exactly what it registered — its models, its provider entry, its credential reference, its auth-provider, and a reference to its transport adapter — and leaves the rest of the registry intact. Two providers that share a transport (openai and openai-codex both use the Responses API) share one adapter through a reference count, so retracting one keeps the other's transport in place. The compatible provider reads its entries from a JSON file keyed by provider id. Each entry names a base URL, an API (`openai-completions` by default, or `openai-responses`), the environment variable that holds its key, and its models. Secrets stay out of the file; only the variable name lives there. A compatible entry registers through the same installer as the built-in providers, so a model you define behaves like a built-in one once it is in the registry. ## Transports: how a selection becomes a call A transport is the adapter that streams one API shape. There are three, keyed by API style, not by vendor: - **anthropic-messages** — the Claude Messages format. Sends the key in an `x-api-key` header. - **openai-responses** — the OpenAI Responses format. Sends a bearer token, and for a ChatGPT account adds the account header. - **openai-completions** — the OpenAI chat-completions format, for compatible endpoints that speak it. Sends a bearer token. When you select a model and the agent needs a turn, the runtime reads the API from the provider, finds the registered adapter for that API, resolves the credential, and hands the request to the adapter to stream. The adapter converts kli's messages and tool descriptors into that API's JSON, opens the stream, and emits a normalized sequence of deltas — assistant text, reasoning, tool calls, usage, stop reason — that the rest of kli consumes without caring which vendor produced them. Keying transports by API rather than by vendor is why one adapter serves more than one provider. openai and openai-codex are distinct providers with distinct base URLs and distinct credentials, but both register the `openai-responses` API, so both stream through the same Responses adapter. A new OpenAI-compatible endpoint reuses an existing transport by declaring an API the registry already knows, so it registers no adapter of its own. ## Why auth modes differ by provider The credential mechanism is a property of the provider, not the model and not the transport. A provider declares its credential when it registers, and the auth layer holds a reference of the matching kind: - **Environment key** (anthropic, openai, and most compatible entries) — the key is read from a named environment variable at call time. The reference stores the variable name, never the secret. - **OAuth** (openai-codex) — kli runs a login flow and persists the tokens; the reference refreshes an expired access token before the call. There is no API key because a ChatGPT account does not issue one. - **Static, persisted key** — a key you set once and kli stores, for providers without an environment variable. The registry uses this only to decide availability — an environment provider is available when its variable is set, an OAuth provider when a usable token is on file — so the model list stays honest without reading any secret. The transport uses it to build the right header: the resolved credential becomes `x-api-key` for Messages and a bearer token for the OpenAI APIs. Different auth modes coexist in one session because each provider carries its own; an OAuth ChatGPT model and an environment-keyed Claude model are both selectable at the same time, and switching between them changes nothing about how the other is authenticated. ## Putting a provider to work To add credentials and pick a model, see [Connect a Provider](/kli/guides/connect-a-provider). To define an OpenAI-compatible endpoint, see [Add a Compatible Provider](/kli/guides/add-a-custom-openai-compatible-provider). For the full list of shipped models, providers, and config keys, see the [model reference](/kli/models). For how a selection drives a turn, see [The Agent Loop](/kli/concepts/the-agent-loop). ## Extend ### Extend kli #### Choosing an Altitude kli extends at three altitudes. They differ by what they can do and how much of the contribution you write, not by a cost you minimize. A few lines of Markdown give you a slash command. A short instruction file gives the model a procedure it reaches for on its own. Lisp gives you tools the model calls, interface changes, and behavior that fires on events. Lisp is the native floor you can always reach, the same kind of object kli itself is built from, not a last resort you fall back to. This page says what each altitude can and cannot do, so you pick by what the task needs. ## Prompt templates A prompt template is a Markdown file that becomes a slash command. The file's name is the command name, and its body is the text sent to the model when you run it. Drop `review.md` in `~/.config/kli/prompts/` (every session) or `/.kli/prompts/` (one project), and `/review` types its body into the conversation as your message. The body is a template, not a fixed string. Placeholders fill in from what you type after the command: `$1` and `$2` for positional arguments, `${@:2}` for everything from the second word on, `$ARGUMENTS` for the whole line. A frontmatter `description` and `argument-hint` show up in command listings. That is the entire feature. Reach for a prompt template when you keep retyping the same instruction. A code-review prompt, a commit-message format, a "explain this file" request with your house conventions: anything you would otherwise paste from a notes file. You decide when it runs, because you type the command. What it cannot do: a template cannot run code, call a tool, read a file on its own, or decide for itself when to act. It is one message, expanded and sent. The model never sees the command itself, only the text it expands to. When you want the model to pull in a procedure without being told, the next altitude does that. See [Prompt Templates](/kli/extend/prompt-templates) for the full placeholder syntax. ## Skills A skill is a `SKILL.md` file with a name and a description. The description is the load-bearing part: kli shows every skill's name and description to the model, and the model reads the skill's body itself, on its own initiative, when a task matches that description. You write the procedure once; the model decides when it applies. That is the difference from a prompt template. A prompt template fires when you type its command. A skill fires when the model judges it relevant, so you do not have to remember it exists at the moment it would help. Put skills in `~/.config/kli/skills//SKILL.md` for every session or `/.kli/skills//SKILL.md` for one project. kli also discovers `.agents/skills/` directories on the way up to the repository root, so skills you already keep for other agent tools are visible too. A skill is still no code. The body is instructions: how to run a migration, the steps your test harness expects, the conventions a reviewer should apply. Files next to `SKILL.md` are referenced by relative path, and the model resolves them against the skill's own directory, so a skill can carry checklists, examples, or scripts the instructions point at. Skills can also be invoked deliberately, as a `/skill:` command or by writing `$name` inside a prompt, and a skill marked `disable-model-invocation: true` is reachable only those ways and never offered to the model automatically. Reach for a skill when the trigger is the task, not the keystroke: when you want a procedure followed whenever it fits, without you naming it each time. The work beyond a prompt template is writing a description sharp enough that the model loads the skill at the right moments and leaves it alone otherwise. What it cannot do: a skill is still text the model reads. It cannot add a tool the model can call, change the terminal UI, bind a key, or run when an event happens. It can only describe what to do with the tools and commands kli already has. See [Skills](/kli/extend/skills) for the `SKILL.md` format and discovery order. ## Lisp extensions A Lisp extension is code. It can add a tool the model calls, a slash command backed by a function, a theme, a keybinding, a status-bar widget, a handler that fires on an event like a tool call, or a new method on existing behavior. kli itself is built out of these contributions, and an extension you write is the same kind of object as the program's own parts. Extensions load from `~/.config/kli/extensions/` and `/.kli/extensions/` as plain Lisp files, and `/reload` re-reads them into the running program. Because kli runs as one live image rather than a fixed binary, an extension installs into the program while it is running and retracts again without a restart. That is what lets you change a tool's behavior and try it in the same session, and it is why a contribution here is reversible rather than permanent: every contribution an extension adds carries a retractor that removes exactly what it installed. A command that replies with text is a handful of lines; you do not need the install/retract machinery in your head to write the first one. Reach for a Lisp extension when Markdown hits its wall: you need the model to call something that does real work (hit an API, query a database, run a computation), you want behavior triggered by events rather than by the model or by you, or you want to change kli's interface, the themes, keys, and status line. It is the only altitude that adds capability rather than text, and it is where everything kli ships already lives. Writing one is programming, in Common Lisp, against kli's extension vocabulary. The built-in `creating-extensions` skill walks through it, and the model can write an extension for you from a description, then reload and test it in the session with you. See [Lisp Extensions](/kli/extend/lisp-extensions) and [The Live Image](/kli/concepts/the-live-image) for how extensions install and retract. ## Picking an altitude Match the altitude to what the task needs: 1. **You retype the same instruction and want a command for it.** Prompt template. Markdown, no code, fires when you type it. 2. **You want a procedure followed whenever a task fits, without naming it.** Skill. Markdown plus a sharp description, no code, the model loads it on its own. 3. **You need a new tool, an event handler, or a change to the interface.** Lisp extension. Code, installed live, the only altitude that adds capability. The altitudes compose rather than rank. A project commonly carries a few prompt templates, a couple of skills, and one extension that adds the tool those skills tell the model to use. Pick the altitude that can do the job, and reach for more than one when the job spans them. #### Prompt Templates You type `/review` in a session and kli sends a long, specific code-review prompt to the model as if you had written it yourself. You wrote that prompt once, in a file. That file is a prompt template. A prompt template is a Markdown file. Its name, minus the `.md`, becomes the slash command; its body is the text kli submits as your message. When you run the command, the body enters the conversation as user input and reaches the agent. The transcript still shows the short command line you typed, while the model sees the full body. No code and no config schema: a file with a name and some text. This is the zero-code altitude of [extending kli](/kli/extend), and it is itself an extension. The builtin `prompt-templates` extension scans your prompts directories and registers one slash command per file, with a retractor that unregisters them on reload; you supply the Markdown, it writes the effect. Reach for it whenever you find yourself retyping the same instructions. ## Where templates live kli reads templates from two places: - `~/.config/kli/prompts/` — global, available in every session. - `/.kli/prompts/` — per project, available only in that project. A file named `review.md` in either directory gives you a `/review` command. Project templates extend the global set and shadow a global template of the same name, so a project can override `/review` with its own version. Discovery is not recursive: kli reads `*.md` directly in those directories, not in subdirectories. ## What the body can do The body is plain Markdown, sent verbatim. It can also take arguments. When you run `/review src/parser.lisp`, placeholders in the body expand from what you typed after the command: `$1` becomes the first argument, `$ARGUMENTS` becomes all of them. A template that needs no arguments simply omits the placeholders. An optional frontmatter block at the top sets a `description` (shown in the command list) and an `argument-hint`. Leave the frontmatter out and kli derives the description from the first non-empty line of the body. The full placeholder syntax and frontmatter keys live in the [reference](/kli/extend/prompt-templates/anatomy). To make one now, follow [Write a prompt template](/kli/extend/prompt-templates/write-your-first). #### Prompt Template Anatomy Drop a Markdown file named `review.md` into a `prompts/` directory and `/review` becomes a command you can run inside a session. Type it, and kli takes the file's body, fills in any arguments you passed, and submits the result as your next message to the model. A prompt template is a saved message you give a name. This page explains the parts of that file and how kli reads them, so you know exactly what `/review` will send before you run it. ## The file is the command kli discovers prompt templates by scanning two directories for `*.md` files: - `~/.config/kli/prompts/` — global templates, available in every project. - `/.kli/prompts/` — project templates, available only when you start kli inside that project. The scan is non-recursive. Only files directly in `prompts/` are read; subdirectories are ignored. The global directory is scanned first, then the project directory, so project templates extend the global set and a project file can shadow a global one of the same name. The command name is the filename with `.md` removed. `review.md` is `/review`; `fix-ci.md` is `/fix-ci`. There is no name field in the file and no registry to edit. To rename the command, rename the file. To add one, add a file. This is why a template needs no installation step: putting it in the directory *is* the registration, and removing it unregisters the command. ## What the frontmatter carries A template may open with a YAML frontmatter block — a `---` line, key/value pairs, a closing `---`. kli reads two keys from it, and only two. **`description`** is the one-line summary shown when you list commands. It tells you and your collaborators what `/review` does without opening the file. If you omit it, kli derives a description from the first non-empty line of the body, truncated past 60 characters. A written description is worth the line, because the fallback is whatever happens to be your opening sentence. **`argument-hint`** is the usage string shown beside the command, the part that reminds you what to type after the name — `` or `[branch] [base]`, for instance. It is documentation for the human running the command. It does not validate or parse anything; the body decides how arguments are actually used. Every other frontmatter key is ignored. The frontmatter is read as flat key/value pairs, and surrounding quotes on a value are stripped. If the file has no complete `---` … `---` fence, kli treats the whole file as body and the frontmatter as empty — so a template with no frontmatter at all still works, it just has a derived description and no hint. ## The body is the message, not a note to the model Everything after the frontmatter is the template body. When you run the command, the body (with arguments substituted in) is submitted as your user message and enters the conversation exactly as if you had typed it. The model sees the expanded text. It does not see the file, the filename, or the frontmatter. The command record itself is marked not model-visible. This is deliberate, not an oversight. The expansion already arrives as the user message through the normal submit path, so a visible command record would prepend a second, duplicate copy of the same text onto the very message the template just sent. Hiding the record keeps one message in the log: the expanded body, attributed to you. The transcript still shows the command line you typed, so you can see that `/review src/parser.ts` is what produced the message. The practical consequence: write the body as the message you want the model to receive, not as instructions about the message. "Review the changes in $1 for correctness and style" is what the model reads. There is no separate layer where you describe the prompt to the agent; the body and the prompt are the same text. ## Loading is fail-soft, and bounded kli reads templates at startup, and a bad file does not break the set. If one template is unreadable or malformed, loading it yields nothing and that file is skipped; the rest of your templates still register. A typo in one file's frontmatter costs you that one command, not all of them. One file can be rejected on size. A template body flows verbatim into the model's context, so a single oversized file read at discovery could exhaust the space the conversation needs. kli caps a prompt template at 2 MiB; a file over the limit is skipped like any other unreadable one. Prompt templates are short messages, so the cap sits far above any reasonable template and exists to contain accidents, not to constrain real use. ## Related - [Write a prompt template](/kli/extend/prompt-templates/write-your-first) — a recipe for creating one. - [Argument substitution](/kli/extend/prompt-templates/using-arguments) — how `$1`, `$ARGUMENTS`, and slices expand in the body. - [Run commands and eval Lisp](/kli/guides/run-commands-and-eval-lisp) — running commands inside a session. #### Write Your First Prompt Template A prompt template is a Markdown file that becomes a slash command. You type `/name` in a session, and kli sends the file's text to the model as your message. In this tutorial you'll write one template and run it. No code, just a file. We'll build a `/review` command that asks kli to review the changes you've staged in git. ## Create the prompts directory kli reads prompt templates from a `prompts/` folder inside your project's `.kli/` directory. Move into the root of any git project and create it: ```sh mkdir -p .kli/prompts ``` ## Write the template Create the file `.kli/prompts/review.md`: ```sh $EDITOR .kli/prompts/review.md ``` Put this in it: ```markdown --- description: Review my staged changes argument-hint: [focus] --- Run `git diff --staged` and review the changes. Look for bugs, missing error handling, and anything that would break existing behavior. If I gave a focus area, pay attention to it: $ARGUMENTS Report what you find. Don't change any files yet. ``` The filename sets the command name: `review.md` becomes `/review`. Everything below the closing `---` is the body, the text kli sends to the model when you run the command. Two things in the frontmatter shape how the command shows up: - `description` is the one-line summary kli displays next to the command. - `argument-hint` is the placeholder kli shows for whatever you type after `/review`. `$ARGUMENTS` is a placeholder in the body. kli replaces it with whatever you type after the command name. Run `/review` with nothing and it expands to an empty string; run `/review error handling` and it expands to `error handling`. Save the file. ## Run it Start kli from the project root: ```sh kli ``` kli finds your template at startup and registers `/review`. Type a single `/` in the prompt, and the completion list appears with `review` among the commands, your description beside it and `[focus]` as the hint. Stage a change first so there's something to review: ```sh git add -A ``` Back in the session, run the command: ```text /review ``` kli expands the template body and sends it as your message. The model runs `git diff --staged`, reads the changes, and reports what it found. You wrote no code, and the diff review is now one command away. Try it with a focus: ```text /review concurrency ``` This time `$ARGUMENTS` expands to `concurrency`, and the model weights its review toward that. ## What you built You have a working slash command in `.kli/prompts/review.md`. It lives in the project, so anyone who clones the repo and runs kli gets the same `/review`. Edit the file and start a fresh session to change what the command does. From here, put the same file under `~/.config/kli/prompts/` to get the command in every project. For the full placeholder syntax, including arguments by position, see [Prompt Template Arguments](/kli/extend/prompt-templates/using-arguments). #### Using Arguments A prompt template is a Markdown file under `~/.config/kli/prompts/` or `/.kli/prompts/` that you run in a session as `/ ...`. Whatever you type after the command name becomes the template's arguments. This page shows how to read those arguments inside the template body. For the file format and frontmatter, see [Prompt template anatomy](/kli/extend/prompt-templates/anatomy). Before substitution, kli splits the text after the command name into arguments: whitespace separates them, and single or double quotes group text into one argument and are dropped. So `/review src/auth.lisp "the login path"` produces two arguments: `src/auth.lisp` and `the login path`. There is no escape handling, and empty arguments never appear. ## Read one argument by position Use `$N` to insert the Nth argument, counting from 1. The placeholder is replaced in place, so you can put it anywhere in a sentence. ```markdown Review the file $1 and focus on $2. ``` Run with `/review src/auth.lisp "error handling"` and the body expands to: ``` Review the file src/auth.lisp and focus on error handling. ``` A position past the end of the supplied arguments expands to an empty string rather than an error. `$0` also expands to empty. ## Read a range of arguments Use `${@:start:len}` to insert a slice of the arguments, joined by single spaces. `start` is the 1-based position of the first argument to take; `len` is how many to take. ```markdown Compare these files: ${@:2:3} ``` Run with `/diff base.lisp a.lisp b.lisp c.lisp d.lisp` and the slice takes three arguments starting at the second, expanding to: ``` Compare these files: a.lisp b.lisp c.lisp ``` `len` is optional. Drop it to take everything from `start` to the end: ```markdown Remaining paths: ${@:2} ``` If `len` runs past the end, the slice stops at the last argument. ## Read all arguments `$ARGUMENTS` and `$@` both expand to every argument joined by single spaces. They are equivalent; use whichever reads better in the body. ```markdown Run the test suite for $ARGUMENTS and report failures. ``` Run with `/test parser evaluator` and the body expands to: ``` Run the test suite for parser evaluator and report failures. ``` Because arguments are joined with single spaces, runs of whitespace and the quotes you typed do not survive here. `/test "the parser"` expands `$ARGUMENTS` to `the parser` without quotes. ## Read the unsplit text `$RAW_ARGUMENTS` expands to everything you typed after the command name, verbatim. It is not split into arguments and not re-joined, so original spacing, quotes, and any characters that look like placeholders are kept literally. ```markdown Commit message: $RAW_ARGUMENTS ``` Run with `/commit fix: handle "empty input" in $1 path` and the body expands to: ``` Commit message: fix: handle "empty input" in $1 path ``` The double space, the quotes, and the literal `$1` all survive. `$RAW_ARGUMENTS` is substituted after the positional, slice, and all-argument placeholders, so a `$1` sitting inside the raw text is never expanded a second time. Reach for `$RAW_ARGUMENTS` when the body needs the input exactly as typed, such as a commit message or a free-form instruction. Reach for `$1`, `${@:start:len}`, `$ARGUMENTS`, or `$@` when you want the input split into discrete arguments. #### Prompt Template Examples Copy any file below into a prompts directory and it becomes a slash command. The filename minus `.md` is the command name, and the body is the prompt sent to the agent when you run it. kli reads templates from two directories, both non-recursive: - `~/.config/kli/prompts/` for commands available in every project. - `/.kli/prompts/` for commands scoped to one repository. The global directory is read first, so a project file with the same name extends or shadows the global set. The command registers when the prompts extension loads. For the full placeholder grammar and frontmatter fields, see [Prompt template anatomy](/kli/extend/prompt-templates/anatomy). ## Write a code review command Save this as `~/.config/kli/prompts/review.md`. It runs as `/review`. ```markdown --- description: Review the staged diff for bugs and unclear code --- Review the currently staged changes. Run `git diff --staged` and read the result. For each change, check for: - Logic errors, off-by-one mistakes, and unhandled edge cases. - Error paths that swallow or misreport failures. - Names and comments that no longer match the code. Report findings grouped by file, most serious first. If a change is correct, say so briefly rather than padding the review. Do not edit any files; this is a read-only review. ``` The `description` line is what appears next to `/review` in the command list. Without frontmatter, kli falls back to the first non-empty body line (truncated past 60 characters), so an explicit description keeps the listing readable. ## Write a commit-message command Save this as `~/.config/kli/prompts/commit.md`. It runs as `/commit`. ```markdown --- description: Draft a Conventional Commits message for the staged diff --- Read the staged changes with `git diff --staged --stat` and `git diff --staged`. Write one Conventional Commits message for them: - A `type(scope): summary` subject line, imperative mood, under 72 characters. - A body that explains why the change was made, wrapped at 72 columns, only when the diff needs it. Print the message in a fenced block. Do not run `git commit` yourself. ``` Both commands above are fixed prompts: they take no input from the command line. Running `/review` sends the whole body to the agent verbatim. ## Pass arguments to a template Trailing text after the command name becomes arguments. Whitespace separates them, and single or double quotes group text into one argument (the quotes are dropped). Reference positional arguments in the body with `$1`, `$2`, and so on, counted from 1. To pass every argument as one string, use `$ARGUMENTS`. The reference covers the rest of the placeholder grammar. Save this template as `~/.config/kli/prompts/explain.md`. The `argument-hint` field shows the expected input next to the command name in the listing. ```markdown --- description: Explain a symbol and where it is used argument-hint: [path] --- Explain the symbol `$1` in this codebase. Search for its definition and its call sites (limit the search to `$2` if that path is given). Then describe, in two or three sentences: - What `$1` does and what it returns. - Who calls it and what would break if it changed. Keep the explanation concrete and tied to the code you found. ``` Run it with arguments after the command name: ```text /explain parseFrontmatter src/config ``` Here `$1` expands to `parseFrontmatter` and `$2` to `src/config`. A positional placeholder with no matching argument expands to an empty string, so `/explain parseFrontmatter` leaves the path clause empty rather than erroring. When you want the trailing text passed through with no parsing, use `$RAW_ARGUMENTS`, which expands to the exact text after the command name. It is substituted last, so any placeholder-looking content inside it stays literal. When you run any of these commands, the agent receives the expanded body as your message. Your transcript keeps the command line you typed (`/review`), while the model sees only the expansion. Next steps: - [Prompt template anatomy](/kli/extend/prompt-templates/anatomy) for the complete placeholder grammar and frontmatter fields. - [/kli/extend/skills](/kli/extend/skills) for reusable instructions the agent loads on its own rather than commands you invoke. #### Skills You ask kli to cut a release. Without being told which file to look at, it reads your release checklist, follows the steps in order, and stops where the checklist says to stop. You wrote that checklist once as a `SKILL.md`. kli found it, decided your request matched it, and loaded it. You never typed a command. That is what a skill buys you over a prompt template. A skill is a `SKILL.md` file the model can load by itself when the task at hand matches the skill's stated purpose. You install the procedure once; the model reaches for it when it is relevant, with no command from you. This is still the zero-code altitude of [extending kli](/kli/extend), and like prompt templates it is itself an extension: the builtin `skills` extension scans your skills directories and registers each one as a command, retracting them on reload. Like a prompt template, a skill is plain Markdown with no code. The difference is who decides to use it. ## How the model finds a skill At the start of a session kli scans for skills and advertises each one to the model: its name, its description, and where the file is. The description is the whole pitch. When the model judges that the task in front of it matches a description, it reads the file and follows the body. So the description is not a caption — it is the trigger. Write it to say plainly what the skill is for and when it applies, because that sentence is what the model matches against. The body stays on disk until it is needed. kli advertises the short description always, and the model pulls in the full instructions only when a task calls for them. A session that never touches the skill never reads its body, so the length of the procedure does not weigh on the rest of your work. ## A skill is a folder The unit is a directory whose name is the skill name and that holds a `SKILL.md`: ``` release-checklist/ SKILL.md template.md scripts/verify.sh ``` The `SKILL.md` body can point the model at the other files in the folder. When it loads the skill, kli tells the model where the folder is and that references resolve against it, so the body can say "run `scripts/verify.sh`" or "fill in `template.md`" and the model knows where those live. A skill is therefore a small bundle: the procedure plus whatever the procedure needs to hand to the model. A single `SKILL.md` with no companion files is also a skill. The folder is the general shape; the lone file is the simple case of it. ## What goes in SKILL.md The file opens with a YAML frontmatter block, and two keys carry the weight. A `name` gives the skill its identity and its command; leave it out and kli uses the folder name, which is the usual practice. A `description` is the text the model matches against, so write it to say concretely what the skill does and the situation it fits. The description is the one key a skill cannot omit. A skill with no description is dropped from the session, because the model would have nothing to match it on. Everything after the frontmatter is the body the model reads once it loads the skill: the procedure itself, in whatever Markdown you like. The exact limits on each field live in the [reference](/kli/extend/skills/anatomy). ## When a skill beats a prompt template The two split on one question: who decides to invoke the instructions. A [prompt template](/kli/extend/prompt-templates) fires when you type its slash command. You are in control, and you reach for it deliberately. That fits a prompt you re-send on purpose — a code review you kick off, a commit message you ask for. A skill fires when the model recognizes the task, whether or not you mention it. That fits a procedure that should apply whenever its situation comes up, even when you did not think to name it: the house style for migrations, the steps for filing a bug, the way this repo wants its changelog written. You encode the knowledge once and let the model apply it in context. Two more facts follow from this. A skill carries a folder of supporting files, where a prompt template is a single Markdown file submitted as your message. And a skill can hold a much longer body without weighing on every session, because the model loads it only when it is relevant — a template's body is sent in full every time you run it. If you want a procedure you trigger on demand, write a prompt template. If you want a procedure the model should apply on its own when the moment arrives, write a skill. ## You can still invoke a skill yourself A skill the model can reach is also a command you can reach. Each discovered skill registers as `/skill:`, so you can load `release-checklist` yourself with `/skill:release-checklist` when you want it now rather than waiting for the model to match it. You can also drop `$release-checklist` into a message and kli expands that skill's body inline before sending. The skill is the same either way; these are extra doors into it. ## Where skills live kli discovers skills from several roots and merges them, with project skills taking precedence over global ones, and a name seen twice keeping the first copy. User-facing roots include `/.kli/skills/` for skills that belong to one project and `~/.config/kli/skills/` for skills you want in every session. kli ships a small set of built-in skills as well, and any skill of yours that shares a name shadows the built-in one. The full discovery order and the rules for what is skipped live in the [reference](/kli/extend/skills/anatomy). To write one now, follow [Write a skill](/kli/extend/skills/write-your-first). #### Skill Anatomy A skill puts a set of instructions in front of the model at the moment a task calls for them. You write the instructions once in a file. The model reads a one- line summary of every skill on every turn, and when a task matches a summary it loads the full file on its own. You can also load a skill yourself, by name. The shape of the file is what makes both work. A skill is a directory with a `SKILL.md` inside it. The file has a small YAML frontmatter block and a Markdown body. The frontmatter is how kli finds and advertises the skill; the body is the instruction text the model reads once the skill is loaded. Everything that decides whether and how a skill reaches the model lives in those few frontmatter keys. ## The frontmatter Three keys appear in the frontmatter. One is required. `description` is required. kli puts it in the list of skills it shows the model each turn, so this is the text the model reads when it decides whether a task matches. Write it to say what the skill is for and when to reach for it, not how it works. A skill with no description is dropped at discovery and never reaches the model. The description is capped at 1024 characters. `name` is the identifier kli uses everywhere it refers to the skill: in the advertised list, in the `$name` sigil, and in the `skill:` command. It must be lowercase, made of `a-z`, `0-9`, and hyphens, with no leading or trailing hyphen and no two hyphens in a row, and at most 64 characters. It must match the name of the directory that holds the `SKILL.md`. If you leave `name` out, kli uses the directory name as the name. Setting it to anything that disagrees with the directory is a validation warning, so in practice the directory name is the name. `disable-model-invocation` is optional. Set it to `true` and kli keeps the skill out of the list it shows the model, so the model never loads it on its own. The skill stays reachable by you, through the `$name` sigil and the `skill:` command. Use it for a skill you want on hand but never auto-loaded. A `SKILL.md` is read whole, and its body lands in the model's context verbatim, so kli reads at most 2 MiB of it. A file over that limit is refused at discovery and refused again if it has grown past the limit by the time the skill is loaded. Keep the body to instructions and link out to anything large. ## How kli finds a skill kli discovers skills by walking a fixed list of directories. A directory that holds a `SKILL.md` is a skill, and its own subdirectories are not searched further. Directories without a `SKILL.md` are searched for nested skills. Dotfiles, `node_modules`, and paths matched by a `.gitignore`, `.ignore`, or `.fdignore` in the tree are skipped. The directories are walked in a set order, and the first skill of a given name wins. Later directories cannot replace a name already taken: 1. The project skills directory, `/.kli/skills/`. 2. `.agents/skills/` directories from the project outward to the repository root. 3. The global skills directory, `~/.config/kli/skills/`. 4. The user agents directory, `~/.agents/skills/`. 5. The skills that ship with kli. Because the built-in skills come last, a skill of the same name in any of your own directories takes precedence over the one kli ships. A second skill that resolves to the same name as an earlier one is dropped and reported as a name collision, so two skills cannot share a name. ## How a skill is invoked A discovered skill reaches the model three ways. The model loads it on its own. Each turn kli appends the list of advertised skills to the system prompt, each as a name, a description, and a location. When a task matches a description, the model reads the file at that location and works from it. This is the path `description` is written for, and the one `disable-model-invocation` turns off. You write `$name` in your message. Typing `$review` puts the body of the `review` skill in front of the model along with your message, before the turn runs. The sigil only triggers at a word boundary and only for a name that matches a discovered skill; `$total` in ordinary prose stays prose. When two skill names share a prefix, the longer match wins. You run `skill:` as a command. Every discovered skill registers a `skill:` command whose description is the skill's. Running it submits the skill body as your message, so the transcript keeps the command line you typed while the model sees the full body. Anything you type after the name is passed along after the body. The first two paths read the description and the name straight from the frontmatter; the third uses the name to build the command. A `SKILL.md` with a clear name and a description that says when to use it is reachable all three ways at once. ## Where to go next Skills are the zero-code altitude of [extending kli](/kli/extend); see [choosing an altitude](/kli/extend/choosing-an-altitude) for how they sit beside prompt templates and Lisp extensions. To write one, follow [Write a skill](/kli/extend/skills/write-your-first). #### Write Your First Skill A skill is a Markdown file of instructions kli hands to the model when a task calls for them. You write the instructions once; the model reaches for them on its own when your request matches what the skill is for. In this tutorial you'll write one skill and trigger it two ways. No code, just a file. We'll build a skill that tells the model how to write a git commit message the way you like them: a short subject line, then a body that explains why the change was made. ## Create the skill directory A skill is a directory holding a file named `SKILL.md`. The directory name is the skill's name, so kli reads the two together. Move into the root of any git project and make the directory: ```sh mkdir -p .kli/skills/commit-message ``` `commit-message` is the name you'll use to call the skill. Keep skill names lowercase, with hyphens between words and no spaces. ## Write the skill Create the file `.kli/skills/commit-message/SKILL.md`: ```sh $EDITOR .kli/skills/commit-message/SKILL.md ``` Put this in it: ```markdown --- name: commit-message description: Write a git commit message for staged changes, following our subject-then-body format. --- Run `git diff --staged` to see what changed. Write a commit message with: - A subject line under 50 characters, in the imperative mood ("Add", not "Added"), with no trailing period. - A blank line. - A body that explains why the change was made, wrapped at 72 columns. Describe the reason, not a restatement of the diff. Print the message. Don't commit anything yet. ``` The frontmatter is two lines that kli reads: - `name` is how you call the skill. Match it to the directory name. - `description` is one sentence saying what the skill is for and when to use it. This is the line the model reads to decide whether the skill fits the task in front of it, so write it as a trigger, not a label. Everything below the closing `---` is the body: the instructions kli gives the model when the skill is invoked. Save the file. ## Start kli and confirm the skill loaded Start kli from the project root: ```sh kli ``` At startup kli walks `.kli/skills/`, finds your `SKILL.md`, and registers the skill. From here, the same skill triggers two different ways. ## Trigger it by name with `$` Write `$` immediately followed by the skill's name anywhere in your message: ```text $commit-message ``` kli sees `$commit-message`, matches it to the skill you wrote, and prepends the skill's body to your message before the model reads it. The model runs `git diff --staged`, then writes the message in your format. Stage a change first so there's something to describe: ```sh git add -A ``` The `$name` sigil is for when you already know which skill you want. You're naming it on purpose. It also works mid-sentence, so you can fold it into a longer request: ```text Stage the auth fix and then $commit-message for it. ``` kli expands `$commit-message` into the skill's instructions and leaves the rest of your sentence untouched. The model follows the skill and applies it to the auth fix you named. ## Trigger it by description You don't have to name the skill. kli also shows the model every skill's `name` and `description` at the start of the session. When your request matches a skill's description, the model loads the skill itself. Ask for the thing the skill is for, without the `$`: ```text Write a commit message for what I've staged. ``` The model reads its list of skills, sees that `commit-message` is described as writing a commit message for staged changes, and loads the body on its own. You get the same formatted message, and you never typed the skill's name. The `description` you wrote is the line the model matched against. ## What you built You have a working skill at `.kli/skills/commit-message/SKILL.md`. It lives in the project, so anyone who clones the repo and runs kli gets the same skill. Edit `SKILL.md` and start a fresh session to change what the model does. To see how the `description` advertisement and the `$name` sigil work, the directories kli searches beyond the project, and the rest of the frontmatter you can set, read [Skill anatomy](/kli/extend/skills/anatomy). #### Authoring and Discovery A skill is a `SKILL.md` file in its own directory. kli finds skills by name from a fixed set of locations, makes each one runnable as `skill:`, and expands a `$name` sigil in your prompt into that skill's content. This page covers where to put a skill so kli finds it, how to override a builtin, and how to invoke a skill once it is found. ## Place a skill where kli looks kli searches these locations, in this order, and stops at the first skill it finds for any given name: 1. The project skills directory: `/.kli/skills/` 2. `.agents/skills/` in each directory from the working directory up to the repo root, nearest first 3. The global skills directory: `~/.config/kli/skills/` 4. `~/.agents/skills/` 5. The skills shipped inside kli (the builtins) To add a skill to one project, create its directory and `SKILL.md` under `.kli/skills/`: ``` /.kli/skills/run-migrations/SKILL.md ``` To make a skill available in every project, put it under `~/.config/kli/skills/` instead. The two `.agents/skills/` locations are read the same way kli reads its own skills, so a skill written for another agent that follows the Agent Skills layout is discovered without changes. A skill's name comes from the `name` field in its `SKILL.md` frontmatter; with no `name` field, kli uses the directory holding the file. Names are lowercase letters, digits, and hyphens. The `.kli/skills/` and `~/.config/kli/skills/` directories also load a plain `.md` file placed directly in them as a single skill; the `.agents/skills/` and builtin locations load skills only from subdirectories that contain a `SKILL.md`. The repo root bounds the upward walk. kli treats a directory holding `.git` as the root and does not look above it. Dot-directories and `node_modules` are skipped during the search, and `.gitignore`, `.ignore`, and `.fdignore` rules under a skills directory exclude matching paths. ## Override a builtin by name The builtins sit last in the search order, so any skill you write with the same name takes precedence. kli keeps the first skill it finds for a name and drops every later one. To replace a builtin named `creating-extensions` for one project, create a skill with that name in the project directory: ``` /.kli/skills/creating-extensions/SKILL.md ``` The project skill now answers to `skill:creating-extensions` and to the `$creating-extensions` sigil; the builtin no longer loads. The same rule shadows a global skill from a project, or a builtin from `~/.config/kli/skills/`. A name that collides between two locations is reported as a diagnostic at startup so the shadowing is visible. ## Reference a skill with the $name sigil Write `$` immediately followed by a skill name anywhere in a prompt, and kli prepends that skill's content before the prompt is sent. The reference itself stays in your text. To pull in the `run-migrations` skill: ``` $run-migrations against the staging database ``` kli reads the longest skill name that matches at the `$`. If you have both `run` and `run-migrations`, `$run-migrations` resolves to `run-migrations`, not `run`. The match must end at a boundary: the character after the name must be something other than a letter, digit, or hyphen (end of line counts). So `$run-migrations` matches but `$run-migrationsx` does not, because the name would have to continue. A `$` only opens a reference when the character before it is not a letter, digit, or another `$`. This keeps `cost$run` and `$$run` from being read as skill references. A `$name` that matches no discovered skill stays as ordinary text, so `$5` is left alone unless you have a skill whose name starts with `5`. Each referenced skill is added once, in the order the references first appear. ## Run a skill with skill:name Every discovered skill registers a command named `skill:`. Run it from the session to load that skill's content as input: ``` skill:run-migrations ``` Anything you type after the name is passed to the skill as arguments: ``` skill:run-migrations --dry-run ``` The command reads the `SKILL.md` body fresh each time it runs, so editing a skill takes effect on the next invocation without a restart. The transcript keeps the command line you typed while the model receives the skill content. ## Related - [Skill anatomy](/kli/extend/skills/anatomy) for the `SKILL.md` format and frontmatter. - [Commands](/kli/commands/slash-commands) for how `skill:` fits alongside other session commands. #### Skill Examples Each section below is a complete `SKILL.md` you can drop into a skills directory and use. Pick the one whose shape matches what you want, copy it, and edit the frontmatter and body. Put the file at `/.kli/skills//SKILL.md` for a skill that belongs to one project, or `~/.config/kli/skills//SKILL.md` for one you want in every session. Leave `name` out and kli uses the folder name; set it to something other than the folder name and kli keeps your `name` but warns. For the discovery rules these examples rely on, see the [skills reference](/kli/extend/skills/anatomy). For why skills exist and how the model reaches them, see [Skills](/kli/extend/skills). ## A domain-knowledge skill This is the common case: a procedure or house rule the model should follow on its own whenever a matching task comes up, without you naming it. The body is the knowledge; the description is what the model matches your request against, so it states plainly what the skill covers and when it applies. Put this at `/.kli/skills/writing-migrations/SKILL.md`: ```markdown --- name: writing-migrations description: House rules for database migrations in this repo - file naming, the up/down structure, and the backfill-then-constraint ordering. Use when writing, reviewing, or editing a schema migration. --- # Writing migrations Every migration is reversible and lands as one file under `db/migrate/`. ## File and naming - One change per file. Name it `__.sql`, e.g. `20260619T0930_add_email_to_users.sql`. - Each file has an `-- up` section and a `-- down` section. The down section must return the schema to its prior state exactly. ## Ordering rules - Add a column nullable first, backfill it in a separate statement, then add the `NOT NULL` constraint. Never add a non-null column with a default to a large table in one step. - Create an index `CONCURRENTLY`. A plain `CREATE INDEX` locks the table. ## Before you finish - Confirm the down section drops exactly what the up section created. - Note the expected row count touched by any backfill in a comment above it. ``` The model loads this only when the description matches the task in front of it, so the body can be as long as the procedure needs. ## A tool-recipe skill A tool recipe is a step list for using a command-line tool the right way: the flags that matter, the order to run things in, what to check after. It packages operational knowledge the model would otherwise have to guess at. Put this at `~/.config/kli/skills/profiling-with-perf/SKILL.md`: ```markdown --- name: profiling-with-perf description: Recipe for CPU-profiling a running process with perf and turning the result into a flamegraph. Use when asked to profile, find a hot path, or explain where time goes in a process. --- # Profiling with perf Sample a running process, fold the stacks, and render a flamegraph. 1. Find the target pid: `pgrep -f `. 2. Record for ten seconds at 99 Hz, capturing call graphs: ```sh perf record -F 99 -p -g -- sleep 10 ``` 3. Collapse the samples and render: ```sh perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg ``` 4. Read the widest frames in `flame.svg` from the bottom up. The widest box that is not a scheduler or idle frame is the hot path. If `perf record` reports no samples, the process is mostly idle or blocked on I/O; switch to `perf record -e sched:sched_switch` to see where it waits. ``` A tool recipe often pairs with companion files. If the body says to run `scripts/setup.sh`, put that script beside `SKILL.md` in the folder; kli tells the model the folder location and that references resolve against it. ## A sigil-invoked skill You can pull any skill into a message yourself by writing `$` in the prompt. kli expands that skill's body inline before sending, so a sigil is the way to reach for a skill on the spot. To make a skill *only* reachable that way and by command, and keep it out of the model's automatic matching, add `disable-model-invocation: true` to the frontmatter. The skill then never shows up in the advertised list, so the model will not load it on its own; you invoke it with `$` or `/skill:`. Put this at `~/.config/kli/skills/explain-like-staff/SKILL.md`: ```markdown --- name: explain-like-staff description: Rewrite an explanation for a staff-level engineer - lead with the tradeoff, drop the basics, name the failure modes. Invoke by hand when you want this lens applied. disable-model-invocation: true --- # Explain like staff Rewrite the explanation that follows for a staff-level engineer. - Open with the decision and its tradeoff, not with background. - Assume fluency in the domain. Cut definitions of standard terms. - Name the failure modes and the conditions that trigger each one. - State what you would measure to know the choice was right. ``` In a message you would then write: ``` $explain-like-staff Here is the draft of the caching section: ... ``` kli prepends the skill body to your message before it reaches the model. A `$name` that matches no discovered skill stays as plain text, so ordinary prose containing a dollar sign is untouched. ## A skill that shadows a built-in kli ships a small set of built-in skills, and they sit last in discovery order. Any skill of yours that has the same name as a built-in wins, because the first skill found under that name is the one kept and your directories are searched first. Give your skill the built-in's exact name to replace its body with yours. To shadow the built-in `creating-extensions` skill with your own house version, put this at `~/.config/kli/skills/creating-extensions/SKILL.md`: ```markdown --- name: creating-extensions description: Author kli user extensions in Common Lisp - commands, event handlers, and tools loaded from ~/.config/kli/extensions/ with hot reload via /reload. Use when asked to create, modify, or debug a kli extension. Adds our team's conventions on top of the basics. --- # Creating kli extensions (team conventions) Follow the standard extension shape, with these additions for our codebase. - Name every extension `-`, e.g. `payments-deploy-guard`. - Project-specific extensions go in `/.kli/extensions/`. Only cross-project tools go in `~/.config/kli/extensions/`. - Every `effect` contribution must pair an installer with a real retractor. Reverting must drain exactly what installing created. ## The minimal shape ```lisp (defextension payments-greet (:provides (command "pay-greet" :description "Greet the payments on-call." :arguments '(:tail :name) :handler (lambda (command arguments context &key call-id on-update) (declare (ignore command context call-id on-update)) (reply (format nil "Hi ~A, you are on call." (or (rest-arg arguments) "there"))))))) ``` After writing the file, run `/reload`, then check `/extensions` shows it enabled. ``` Because your file is found before the shipped one, the model and the `/skill:creating-extensions` command both load your version. If you remove your copy, the built-in returns on the next discovery pass. Naming must be exact: a different name does not shadow, it adds a second skill. ## Verify a skill loaded After you add or edit any of these files, kli re-discovers skills on the next session start. Confirm the result by invoking the skill yourself with `/skill:` — every discovered skill registers under that command, so a skill that runs there is one kli found and parsed. A skill with no `description` is dropped silently, so if `/skill:` is missing, check the frontmatter first. #### Lisp Extensions A Lisp extension lets you give the model a tool it can call, back a slash command with a function, bind a key, change the theme, change how a tool's output is drawn, or run code when something happens in the session. This is where you stop describing what kli should do and add behavior it did not have, and where you can change that behavior and try it in the same session, without restarting. The zero-code altitudes are text. A prompt template is a message you send; a skill is a procedure the model reads. Neither can run code, call out to a system, or touch the interface. When you need any of those, you write an extension. This is not a fallback for when Markdown runs out: it is the native floor, the same kind of object kli itself is built from. ## What an extension adds An extension is a list of contributions. Each contribution is one named thing the extension installs into the running program, and each kind of contribution is a different thing the model, the session, or the interface gains. The kinds you reach for: - **Tool.** A function the model can call by name, with a description, a parameter schema, and a runner that does the work. This is how the model hits an API you have, queries your database, runs a computation, or drives a system kli does not know about. The runner returns content the model reads back, and the tool can carry a renderer that controls how its call shows up in the terminal. - **Command.** A slash command backed by a function rather than a Markdown body. Where a prompt template expands to text, a command runs code when you type it — inspect the session, write a file, call into the program, print a result. - **Keybinding.** A key bound to an action in the terminal interface. - **Theme.** A palette the interface draws with. - **Renderer.** Code that decides how a message or a tool call is drawn in the terminal, so you can change what you see without changing what the model does. - **Status-bar slot and widget.** A piece of the interface that shows your own state. - **Event handler.** A function that fires when something happens — a tool call, a turn boundary, an event your own code emits — so behavior can be triggered by the session rather than by you or the model. - **Method.** A new method on an existing generic function, which is how you adjust behavior kli already has instead of adding something beside it. You are not working with a separate plugin API bolted onto the side of kli. kli itself is built out of these same contributions: the model providers, the tools that read and edit your files, the slash commands, and the terminal interface are all extensions, installed at startup the same way yours installs. An extension you write is the same kind of object as the program's own parts, which is why it can reach the same places they do. ## Where extensions live and how they load Extensions are plain Lisp files. kli reads them from `~/.config/kli/extensions/` for every session and from `/.kli/extensions/` for one project. A file declares an extension with a name, what it requires, and what it provides, and kli installs it on startup. `/reload` re-reads those files into the running program. It retracts every user extension currently installed, re-indexes the files on disk, and installs them again — so the loop is edit the file, run `/reload`, use the change, with no rebuild and no restart. A file that fails to load is reported and skipped; the rest keep working, and you fix it and reload again. ## Recoding while it runs The thing this tier gives you beyond capability is that the capability is editable in place. kli runs as one live program rather than a fixed binary, so an extension installs into the session while it is running and can be replaced or removed without ending it. Replacing an extension is a single operation. kli deactivates the running version — draining the tools, commands, keybindings, and methods it had installed — then activates the new source. If the new version fails to come up, kli brings the old one back and reports the error, so a broken edit leaves you where you started instead of in a half-applied state. This is why you can change a tool's behavior, reload, and call it again in the same conversation, watching the difference turn by turn. Removal is the same symmetry from the other side. Every contribution an extension adds has a matching way to remove it, and deactivating an extension takes out exactly the pieces it installed and nothing else. Your context, your session log, and your model connection are untouched by adding or removing an extension. [The Live Image](/kli/concepts/the-live-image) covers the mechanism behind this; you do not need it to write an extension, but it is the reason the edit-reload-try loop exists. ## What it costs Writing an extension is programming, in Common Lisp, against kli's extension vocabulary. A command that replies with a fixed string is a few lines; a tool that does real work is as much code as the work takes. The altitudes compose rather than rank: a project commonly carries a few prompt templates, a couple of skills, and one extension that adds the tool those skills tell the model to use. [Choosing an altitude](/kli/extend/choosing-an-altitude) lays out what each can and cannot do. You do not have to write the Lisp yourself. The built-in `creating-extensions` skill walks the model through authoring one, so you can describe the tool or command you want and have the model write, reload, and test it in the session with you. Adding a tool means the model can now call it, which is also something you may want to restrict. A tool can declare the capabilities it needs, and kli refuses the call if the current session does not hold them. See [Capabilities and fault barriers](/kli/concepts/capabilities-and-fault-barriers) for how that gating works. ## Related - [Choosing an altitude](/kli/extend/choosing-an-altitude) — what a Lisp extension can do that a template or skill cannot, and when each is enough. - [The Live Image](/kli/concepts/the-live-image) — how extensions install, retract, and recode without a restart. - [Sharing extensions](/kli/extend/sharing-extensions) — handing an extension to someone else, including the `/install ` consent flow. - [Tools](/kli/commands/tools), [Slash commands](/kli/commands/slash-commands), [Themes](/kli/commands/themes), [Keymap](/kli/commands/keymap) — the built-in contributions an extension extends. #### Lisp Extension Anatomy 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 `/.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](/kli/concepts/the-live-image) — why behavior is editable while kli runs, the mechanism this anatomy rests on. - [Capabilities and Fault Barriers](/kli/concepts/capabilities-and-fault-barriers) — the capability set that gates installing, retracting, and recoding. - [Profiles](/kli/concepts/profiles) — how groups of extensions are bundled and selected. #### Write Your First Lisp Extension By the end of this page you will have a working slash command, `/greet`, that you wrote, loaded into a running kli, edited, reloaded without restarting, and switched off. You will do all of it against one live session. You write an extension as a Common Lisp file on disk. kli reads the file and installs the command it declares. There is no build step and no restart. ## Create the file Extensions in `~/.config/kli/extensions/` load in every session. Create that directory if it does not exist, then write a file named `greet.lisp` inside it. ```lisp (defextension greet (:provides (command "greet" :description "Greet someone by name." :arguments '(:tail :name) :handler (lambda (command arguments context &key call-id on-update) (declare (ignore command context call-id on-update)) (reply (format nil "Hello ~A!" (or (rest-arg arguments) "world"))))))) ``` That is the whole extension. Reading it line by line: - `defextension greet` names the extension `greet`. - `(command "greet" ...)` declares the slash command. The string is the name you type after the slash. - `:arguments '(:tail :name)` captures everything typed after `/greet` as one free-text tail. - The handler signature `(command arguments context &key call-id on-update)` is fixed; declare-ignore the parameters you do not use. - `(rest-arg arguments)` returns that captured tail, or `nil` when you typed nothing after the command name. - `(reply text)` builds the result kli shows. A single `.lisp` file loads in the `kli/author` package, so `defextension`, `command`, `reply`, and `rest-arg` are available unqualified. You import nothing. ## Load it into the running session Switch to your kli session and run: ``` /reload ``` `/reload` re-reads every extension file on disk and installs what it finds. kli prints the list of user extensions it now knows about; `greet` is among them, marked enabled. Now run the command: ``` /greet Ada ``` kli answers `Hello Ada!`. Run it with no argument: ``` /greet ``` kli answers `Hello world!`, because `(rest-arg arguments)` returned `nil` and the `or` fell through to the default. ## Edit it and reload live You can change the command against the same running session. Open `greet.lisp` and change the greeting. ```lisp (reply (format nil "Welcome, ~A." (or (rest-arg arguments) "stranger"))) ``` Save the file, return to kli, and run `/reload` again. `/reload` retracts every installed user extension, re-reads the files, and reinstalls them, so your edit replaces the old command in place. Run `/greet Ada` and kli now answers `Welcome, Ada.`. If a file has an error, loading is fail-soft: kli warns about that one file and isolates it while every other extension keeps working. Fix the file and `/reload` again. ## Switch it off To take the command out of the session without deleting the file, disable it by name: ``` /disable greet ``` kli answers `Disabled greet.`, and `/greet` no longer runs. The file stays on disk, so a later `/reload` (or the next session) brings it back. To list what is available and which extensions are currently enabled, run: ``` /extensions ``` ## What you did You wrote a slash command in a Lisp file, loaded it with `/reload`, edited it and reloaded it against the same live session, and disabled it with `/disable`. Edit, `/reload`, repeat: that is how you build a kli extension. From here: - Add a command that reacts to session events, or one the model can call as a tool: see [Lisp extensions](/kli/extend/lisp-extensions). - Understand why a running kli can change itself this way: see [The live image](/kli/concepts/the-live-image). - See the full `defextension` grammar, every contribution kind, and the author DSL: see [Extension reference](/kli/extend/lisp-extensions/anatomy). #### Contribution Kinds An extension is a bundle of contributions. Each contribution is one clause inside a `(:provides ...)` block, and each one installs when the extension loads and retracts when it unloads. This page shows the clause for each kind you are likely to write, and what its retraction undoes. Pick the kind that matches what you want to add. Every clause shown here goes in a `defextension`: ```lisp (defextension my-extension (:provides ;; one or more contribution clauses )) ``` A single-file extension loads in the `kli/author` package, so the clause heads below (`command`, `on`, `tool`, and the rest) are available unqualified. After editing a file, run `/reload` to retract and reinstall. For the surrounding mechanics, see [Write your first Lisp extension](/kli/extend/lisp-extensions/write-your-first); for the complete grammar and every clause, see [Extension reference](/kli/extend/lisp-extensions/anatomy). Requirements are derived from the clauses you write. A `command` clause, for example, derives the requirement for the commands capability, so an extension that needs nothing beyond what its clauses imply omits `:requires` entirely. ## Add a slash command A `command` clause adds a `/name` the user can type. ```lisp (command "greet" :description "Greet someone by name." :arguments '(:tail :name) :handler (lambda (command arguments context &key call-id on-update) (declare (ignore command context call-id on-update)) (reply (format nil "Hello ~A!" (or (rest-arg arguments) "world"))))) ``` The handler signature is fixed: `(command arguments context &key call-id on-update)`. `(reply text)` builds the result shown to the user; `(rest-arg arguments)` returns the free-text tail after the command name, or `nil`. Keys you can pass: `:label`, `:description`, `:arguments`, `:handler`, `:completer`, `:metadata`. Retraction unregisters the command from the per-session command provider. After unload, typing `/greet` does nothing. ## Add an event handler An `on` clause runs a function each time an event of a given type is dispatched. ```lisp (on :tool/call (lambda (event context) (declare (ignore event)) (notify context "A tool ran." :level :info))) ``` The handler signature is `(event context)`. `(notify context text :level :info)` surfaces text to the user as a notification, and is a no-op when no event provider is installed. Handlers for the same event type fire in install order. An event handler carries no requirement of its own: it sits inert in per-session handler storage until the event system dispatches its type. Retraction removes the handler from that storage, so the function stops firing. ## Add a tool A `tool` clause adds a tool the model can call during the agent loop. ```lisp (tool word-count :label "Word Count" :description "Count the words in a string." :parameters '(:object (:text :string)) :runner #'my-word-count :metadata '()) ``` The runner signature is `(tool parameters context &key call-id on-update)` and returns a tool result. Keys: `:label`, `:description`, `:parameters`, `:runner`, `:renderer`, `:metadata`. Use `:metadata '(:capabilities (...))` to declare the capabilities the tool needs, and `:renderer` to control how its result appears in the transcript. The tool registers as a live object in the session and joins the set the model sees. Retraction removes the live object and drops the tool from that set, so the model can no longer call it. ## Bind a key A `keybinding` clause maps a key to an editor action in the terminal UI. ```lisp (keybinding "ctrl+x" :clear-screen) ``` The first argument is the key id, a string like `"ctrl+l"`, `"alt+b"`, or `"enter"`. The second is the action keyword it should run, drawn from the editor's action set (for example `:clear-screen`, `:move-word-left`, `:delete-word-backward`, `:undo`). Installing a binding records whatever action that key previously held. Retraction restores the previous binding if the key had one, or unbinds the key if it did not. When two extensions both touch a key, retraction unwinds in reverse, so the original binding returns. ## Add a theme A `theme` clause registers a named theme the user can switch to. ```lisp (theme "solarized" (kli/tui/style:load-theme #p"~/.config/kli/themes/solarized.json")) ``` The first argument is the theme name; the second is a theme value. `kli/tui/style:load-theme` reads a theme from a pathname, a JSON string, or a parsed object, resolving its color tokens once at load time. Registering a theme does not make it active; the user selects it. Retraction unregisters the theme by name. After unload it no longer appears in the list of available themes. ## Add a status slot A `status-slot` clause reserves a named slot in the status line that your code can write to. ```lisp (status-slot :build :initial "idle") ``` The first argument is the slot id; `:initial` sets the starting text (default empty). The slot holds whatever text you put in it and renders in the status line. Retraction unregisters the slot, removing it from the status line. ## Render a message kind A `message-renderer` clause controls how a transcript event of a given kind is drawn. ```lisp (message-renderer :build/progress (lambda (event theme width) (declare (ignore width)) (list (kli/tui/style:style theme "accent" (format nil "build: ~A" (getf (kli/event:event-payload event) :stage)))))) ``` The first argument is the event kind; the second is a function `(event theme width)` returning the lines to draw. `(kli/tui/style:style theme token text)` colors a span by a theme token; `(kli/event:event-payload event)` reads the event's payload plist. A renderer is a method keyed on the event kind, so it takes effect only for events of that kind. Retraction removes the method, and the kind falls back to default rendering. ## Declare a settings subtree A `settings` clause declares your extension's subtree of the top-level `extensions` object in `settings.json`: the key names it accepts and the schema each value must satisfy. ```lisp (settings my-extension (:object ("greeting" (:string :default "hello")) ("retries" (:integer :min 0 :max 5)) ("mode" (:enum ("fast" "careful") :default "fast")))) ``` The clause is `(settings NAME SCHEMA)`. A symbol NAME downcases to the JSON key (`my-extension` above owns `extensions.my-extension`); pass a string when the key needs exact casing. The schema is quoted data: | Spec | Accepts | | --- | --- | | `(:object ("key" SPEC) ...)` | An object with the listed keys, each validated by its spec. Keys not listed diagnose as unknown. | | `(:string)` | A string. | | `(:boolean)` | `true` or `false`. | | `(:integer :min N :max M)` | An integer, optionally bounded. | | `(:number :min N :max M)` | A number, optionally bounded. | | `(:enum ("a" "b"))` | One of the listed strings. | | `(:or SPEC SPEC ...)` | A value matching any alternative. | Every leaf takes `:default`, and every declared key is optional in the files. Read values back with `(kli/config:declared-settings-value context "my-extension" "retries")`; it returns the configured value, or the declared default when the files omit the key, and a second value saying which (`:settings`, `:default`, or `nil` for neither). Declaring buys three things. The subtree is validated when your extension activates, and every mismatch warns with the exact path — boot diagnostics the user sees instead of a silently ignored key. `/settings` lists the subtree, whether the files carry it, and its current diagnostics. And the subtree tiers like every built-in key: global under project under profile overlay, deep-merged key by key. See [settings.json](/kli/config/settings#extensions) for the user-side view. A malformed schema signals when the extension loads — that is an authoring error. A malformed *value* in the user's files only warns: settings never break boot. And declaration describes, never grants — no key in your subtree can confer authority; what your tools may do is governed by [capabilities](/kli/config/capabilities) alone. Retraction removes the declaration from the registry. The JSON stays in the user's files; `/settings` then lists that subtree as undeclared. ## Anything else: an effect When no kind above fits, an `effect` clause runs arbitrary paired install-and-revert logic. This is the general escape hatch: you write both halves, and you own the symmetry. ```lisp (effect mirror-log ;; installer (lambda (protocol contribution context) (declare (ignore protocol contribution context)) (open #p"/tmp/kli-mirror.log" :direction :output :if-exists :append :if-does-not-exist :create)) ;; retractor (lambda (protocol contribution context) (declare (ignore protocol context)) (close (kli/ext:contribution-state contribution)))) ``` The clause is positional: `(effect NAME installer retractor)`. Both functions take `(protocol contribution context)`. The installer's return value is stored as the contribution's state; the retractor reads it back with `(kli/ext:contribution-state contribution)` to undo exactly what was installed. The example stores the open stream on install and closes it on retract. The retractor is required, because retraction must drain whatever install created: unregister what was registered, restore what was replaced, close what was opened. When an effect genuinely has nothing to undo, pass `:no-op` as the retractor rather than omitting it. ```lisp (effect announce (lambda (protocol contribution context) (declare (ignore protocol contribution)) (notify context "Extension loaded." :level :info)) :no-op) ``` State that must survive a turn but die with the extension belongs in protocol storage, not in a global. Reach it with `(kli/ext:ensure-protocol-storage protocol KEY constructor)`; a global outlives retraction and leaks across reloads. The clause heads above are matched by name, so they work unqualified from any package. The helper functions you call inside a clause are different: a single-file extension gets only the easy-tier names unqualified (`defextension`, `kli-extension`, `command`, `on`, `reply`, `rest-arg`, `notify`). Everything else, including the `kli/ext`, `kli/event`, and `kli/tui/style` names shown here, must be package-qualified. ## These kinds in tree The clauses above are minimal on purpose. Every kind also ships in kli's own builtin extensions, where you can read it doing production work: - **Tool** — the builtin `bash`, `filesystem`, and `lisp` tool extensions. - **Slash command** — the builtin `install`, `settings`, and `profile` commands. - **Theme** — the builtin theme extension registers the `:dark` and `:light` themes with `(theme :dark (load-theme ...))`. - **Effect** — the builtin `prompt-templates` and `skills` extensions, and cairn, each register commands or open state through an `effect` with a paired retractor. - **Keybinding, status slot, widget, message renderer** — defined and contributed by the keymap and terminal-UI subsystems. cairn is a full external extension to read end to end: one manifest with a store-opening effect, its model tools, a live context hot-patch, and its own slash commands, each contribution carrying a retractor. To add a kind that none of these cover, see [Defining a contribution kind](/kli/extend/lisp-extensions/defining-a-contribution-kind). ## Next - The full `defextension` grammar and every contribution kind: [Extension reference](/kli/extend/lisp-extensions/anatomy). - Why a running kli can install and retract these live: [The live image](/kli/concepts/the-live-image). #### Defining a Contribution Kind The contribution kinds are not a fixed enum. A kind is a compile-time function from a `(:provides ...)` clause to a contribution value, registered under a keyword. The kernel defines its entire vocabulary this way, with the same macro you would use to add one. There is no privileged set of built-in kinds and a separate plugin path for the rest; `:tool` and a kind you write yesterday are registered identically. ## The macro ```lisp (defcontribution-kind kind (extension-id form) &body body) ``` The body is a quasiquote returning the form that constructs your contribution; `defcontribution-kind` registers it as the form-compiler for `kind`. When `defextension` parses a `(:provides ...)` block it dispatches on each clause head to the matching compiler. A clause whose head has no registered compiler signals `unknown-contribution-kind`, so a kind exists exactly when its compiler is registered. The compiler runs at macroexpansion of `defextension`, not at install. Its job is narrow: read the clause syntax, return code that builds one contribution object. The installing and retracting happen later, through methods on that object. ## The shape A kind is three pieces: a contribution class, an install/retract method pair specialized on it, and the `defcontribution-kind` compiler that builds it from a clause. A synthetic `:banner` kind that registers a startup line: ```lisp (defclass banner-contribution (kli/ext:contribution) ((text :initarg :text :reader banner-text))) (defmethod kli/ext:install-contribution ((protocol kli/ext:extension-protocol) (c banner-contribution) context) (declare (ignore context)) (register-banner protocol (banner-text c)) (push c (kli/ext:protocol-installed-contributions protocol)) c) (defmethod kli/ext:retract-contribution ((protocol kli/ext:extension-protocol) (c banner-contribution) context) (declare (ignore context)) (unregister-banner protocol (banner-text c)) (setf (kli/ext:protocol-installed-contributions protocol) (remove c (kli/ext:protocol-installed-contributions protocol))) c) (kli/ext:defcontribution-kind :banner (extension-id form) (destructuring-bind (_ text) form (declare (ignore _)) `(make-instance 'banner-contribution :kind :banner :text ,text :source ',extension-id))) ``` Once those three forms load, a `(banner ...)` clause compiles inside any extension: ```lisp (defextension welcome (:provides (banner "kli ready."))) ``` The clause head is matched by name, so `banner` works unqualified in the author package; the helpers it expands into (`register-banner`, the `kli/ext` symbols) are ordinary functions and stay package-qualified. ## The kernel does exactly this `:theme` is the same five-part shape, in tree: ```lisp (defcontribution-kind :theme (extension-id form) (destructuring-bind (_ name theme-form) form (declare (ignore _)) `(make-theme-contribution :name ',(normalize-extension-id name) :theme ,theme-form :source ',extension-id))) ``` The `:theme` kind ships with its own `theme-contribution` class, a constructor, and the install/retract pair beside it. Nothing about it is special: it is a self-contained domain kind that a subsystem registers when it loads. The core kinds the protocol appears to "know" are the same. They are `defcontribution-kind` forms too: `:effect`, `:method`, `:tool`, `:capability`, `:live-object`, `:contract`, and `:grant`. `:method` compiles to a `make-method-contribution` that carries a generic-function name, qualifiers, specializers, and body; its retractor is `remove-method`. `:tool` compiles to a `make-tool` wrapped in a contribution. The kernel reaches for the macro you do. The set stays open across the tree. Each subsystem registers its own kinds at load: `:command`, `:keybinding`, `:event-type` and `:event-handler`, and the terminal-UI kinds `:message-renderer`, `:status-slot`, and `:widget`. A kind lives wherever its domain lives, not in a central registry of permitted types. ## The reversibility contract A kind is real only when its `install-contribution` and `retract-contribution` are symmetric on `(extension-protocol, your-contribution)`. The `:theme` pair is the minimal correct form: install registers the theme and pushes the contribution onto `protocol-installed-contributions`; retract unregisters by name and removes the contribution by identity. That symmetry is not decoration. Deactivation walks the contributions an extension recorded at install and calls `retract-contribution` on each (see [Lisp extension anatomy](/kli/extend/lisp-extensions/anatomy)), so a kind whose retract does not undo its install leaks on every `/disable` and `/reload`. Three rules keep a custom kind honest: - **Register and unregister the same name.** Whatever install files under a key, retract removes under that key. - **Record the contribution, drop it by identity.** Push on install, `remove` the same object on retract, so deactivation has an exact list rather than a guess. - **Keep state with the contribution or in protocol storage, never in a global.** A slot on the contribution (`banner-text` above) or `(kli/ext:ensure-protocol-storage protocol KEY constructor)` dies with the protocol; a `defvar` outlives retraction and leaks across reloads. The `:effect` kind threads its state through `contribution-state` because it has no class of its own; a kind with a class keeps state in slots, as `:banner` does. ## Related - [Contribution kinds](/kli/extend/lisp-extensions/contribution-kinds) — the kinds you reach for before defining one of your own. - [Lisp extension anatomy](/kli/extend/lisp-extensions/anatomy) — install as a recorded transaction, and why every kind retracts. - [Extensions all the way down](/kli/concepts/extensions-all-the-way-down) — why the kind vocabulary being contributed the same way is the whole point. - [The live image](/kli/concepts/the-live-image) — why a running kli can register a new kind and start compiling clauses against it without a restart. #### Recoding Live You can change kli's behavior in the session you are already in, without restarting it. The recode operations below are ordinary functions: one swaps a whole extension, two hot-patch a single behavior in place. Each takes the running `context` and the object it acts on, and each is gated by a capability, so a restricted session can deny it. This page assumes you have written or loaded the extension you want to change. For the extension shape itself, see [Write your first Lisp extension](/kli/extend/lisp-extensions/write-your-first). For why a running kli can do this at all, see [The live image](/kli/concepts/the-live-image). ## Where the recode call runs A recode needs the live `context` and `protocol`. Both arrive as arguments wherever your extension's code already runs: a command handler is called as `(command arguments context &key call-id on-update)`, and an effect installer as `(protocol contribution context)`. Call the recode functions from inside one of those, where the objects are already in hand. The examples below show a command handler that does the recode when the user types its command. To reach the active protocol from a context, use `(kli:active-protocol context)`; to find a live object by its id, use `(kli:find-live-object (kli:context-registry context) id)`. ## Swap a whole extension Use `recode-extension` to replace one extension with a new version while everything else keeps running. It deactivates the old extension, then activates the new source. If activation fails, it re-activates the original, so the session is left on the version that worked rather than in a half-installed state. ```lisp (command "swap-greeter" :description "Replace the greeter extension with its next version." :handler (lambda (command arguments context &key call-id on-update) (declare (ignore command arguments call-id on-update)) (let* ((protocol (kli:active-protocol context)) (extension (kli:find-live-object (kli:context-registry context) :greeter))) (kli/ext:recode-extension protocol extension #'greeter-v2 context) (reply "Greeter swapped.")))) ``` The third argument is the new source: the same kind of value you would activate an extension from, such as a manifest thunk or a `defextension` name. Deactivation retracts the old extension's tools, commands, providers, and methods together; activation installs the new one's. A consumer of the extension sees the swap as a single step. `recode-extension` requires the `image/recode` capability. Granting `image/recode` also grants `manifest/install` and `manifest/retract`, because a recode is a retract followed by an install. A session whose `capabilities` array omits `image/recode` cannot swap extensions; see [Restrict what kli can do](/kli/guides/restrict-what-kli-can-do). ## Hot-patch a behavior cell When you want to change one function rather than a whole extension, patch a behavior cell. A behavior cell holds a single function behind a fault barrier, and `recode-behavior` swaps that function in place. The cell keeps its identity, its version counter increments, and callers go on calling the same cell. ```lisp (let ((cell (kli:find-live-object (kli:context-registry context) :my-behavior))) (kli/tui/core:recode-behavior cell :function #'my-new-function)) ``` `recode-behavior` takes the cell and keyword arguments: - `:function` — the new function to run. - `:version` — set the version explicitly; omit it to increment by one. - `:state` — replace the cell's state. - `:metadata` — replace the cell's metadata. - `:capabilities` — replace the capability list the cell declares. `recode-behavior` requires the `behavior/hotpatch` capability. Passing `:state` additionally requires `behavior/state`, because changing live state is a stronger act than swapping the function; a session can be allowed to patch functions while still being denied state edits. The cell's fault policy and fault fallback are set when the cell is built and a recode cannot touch them, so a patched function that throws is still contained by the barrier it was installed behind. ### Patch a terminal-UI behavior Terminal-UI components expose their behaviors through `recode-tui-behavior`, a generic that dispatches on what you hand it. Given a behavior cell, it delegates to `recode-behavior` with the same keyword arguments. Given a concrete UI object — an editor, the transcript, the input decoder, a frame renderer — the owning extension specializes it to find the right cell and patch that. ```lisp (kli/tui/core:recode-tui-behavior (kli:find-live-object (kli:context-registry context) :editor) :function #'my-editor-input-handler) ``` The gating is the same: `behavior/hotpatch`, plus `behavior/state` when you pass `:state`. The UI behaviors that ship — editor input and paste, transcript scrollback, input decoding, frame rendering — each declare `behavior/hotpatch` and `behavior/state`, so a restricted session can permit or deny patching them as a group. ## Recode a policy in place Some behavior is not a whole extension or a single behavior cell but a field of a policy on a live service. The session's context transform, which decides what extra messages are spliced into each turn, is one such policy. `kli/agent/session:recode-context-transform-policy` rebuilds that policy with one field replaced and leaves the rest intact. The new function runs behind the session fault barrier, so a transform that throws yields no extra messages rather than breaking the turn. cairn uses this in tree to splice live task context into every turn. Its `cairn-context` effect reads the service's current `extra-messages-fn`, saves it, and recodes the policy to a function that appends cairn's task messages onto whatever the previous one returned: ```lisp (let ((service (kli:find-live-object (kli:context-registry context) :agent-session-service))) (when service (let ((previous (getf (funcall (kli/agent/session:session-context-transform-policy service) :inspect) :extra-messages-fn))) (kli/agent/session:recode-context-transform-policy service :extra-messages-fn (lambda () (append (and previous (funcall previous)) (cairn-extra-messages context)))) (list :service service :previous-fn previous)))) ``` The effect's retractor reverses it exactly, recoding the same field back to the saved `previous-fn`. The session never restarts and never loses its scrollback: one field of a live policy is swapped, and swapped back on retract. This is the second pillar in production, [rewrite without restarting, keep the state](/kli/concepts/extensions-all-the-way-down). Unlike the kernel recodes above, it is a plain function on the agent-session service rather than a capability-gated kernel op; an extension reaches it through the live object and keeps the saved state on its own contribution. ## Snapshot the active protocol A snapshot captures the active protocol as durable data: the list of installed extensions in activation order, the protocol's storage, and the serializable slot state of every contributed live object. Take one before a recode you are unsure about, or to move a session's state to another image. ```lisp (let ((snapshot (kli/ext:provider-call (kli/ext:require-capability-provider (kli:active-protocol context) :runtime/snapshot) :snapshot-context context))) snapshot) ``` A snapshot is honest about what it cannot carry. A value it cannot serialize is named, not encoded lossily: an extension with no reconstructable manifest is listed under `:unrestorable-extensions`, storage entries it skipped under `:skipped-storage`, and per-object slots it dropped under `:skipped-slots`. What it skips is code-derived structure that reinstalling the manifests rebuilds, so the snapshot records the names rather than the bytes. To restore, call `:restore-active-protocol` with a snapshot. A still-registered protocol is rehydrated in place; a protocol that was discarded, or one absent because the image restarted, is rebuilt from scratch by installing the captured manifests in order and rehydrating the captured storage and slot state. ```lisp (kli/ext:provider-call (kli/ext:require-capability-provider (kli:active-protocol context) :runtime/snapshot) :restore-active-protocol context snapshot) ``` `snapshot-context` requires `protocol/snapshot` and `restore-active-protocol` requires `protocol/restore`. A session can be allowed to capture state without being allowed to overwrite it. ## The capabilities each step needs A session that omits one of these from its `capabilities` array is denied that step; a session with the key absent runs fully permissioned and can do all of them. For the full model and how to set the array, see [Permissions and capabilities](/kli/concepts/capabilities-and-fault-barriers). | Operation | Capability | Notes | | --- | --- | --- | | Evaluate a form ad hoc | `image/eval` | Gates the `/eval` command and `eval` tool, the way to run a recode form without writing an extension. | | Swap an extension | `image/recode` | Implies `manifest/install` and `manifest/retract`. | | Patch a behavior function | `behavior/hotpatch` | Covers `recode-behavior` and `recode-tui-behavior`. | | Change a behavior's state | `behavior/state` | Required in addition when you pass `:state`. | | Capture a snapshot | `protocol/snapshot` | — | | Restore a snapshot | `protocol/restore` | — | ## Next - The capability names and the tools each one gates: [Capabilities](/kli/config/capabilities). - How a faulting recode stays contained instead of killing the session: [Permissions and capabilities](/kli/concepts/capabilities-and-fault-barriers). - The full `defextension` grammar: [Extension reference](/kli/extend/lisp-extensions/anatomy). #### Loading and Managing Extensions This page covers how a Lisp extension reaches a running kli and how you turn one on or off without restarting. For writing the extension itself, see [Write your first Lisp extension](/kli/extend/lisp-extensions/write-your-first); for the full `defextension` grammar, see [Extension reference](/kli/extend/lisp-extensions/anatomy). An extension comes from a file or directory on disk. kli reads that source, indexes what it declares as an available extension, and installs the enabled ones into the session. Four things decide which extensions a session has: where kli looks, what `config.json` says, the `--extension` flag, and the in-session commands. ## Drop a file in a discovery directory kli scans two directories on every launch, in this order: 1. `~/.config/kli/extensions/` — loaded in every session. 2. `/.kli/extensions/` — loaded only when kli runs in that project. Project files extend the global set; they do not replace it. A directory that does not exist is skipped, not an error. To add an extension, write its `.lisp` file (or its unit directory) into one of these and start kli, or run `/reload` in a session already open. ## Load a one-off file with --extension To load a file or directory that is not in a discovery directory, name it on the command line: ``` kli --extension ./scratch/greet.lisp ``` The flag is repeatable, and each value is either a file or a directory: ``` kli --extension ./scratch/greet.lisp --extension ./team-extensions/ ``` A path that is a directory is discovered as a unit (or a group of units); any other path loads as a single file. Extensions named with `--extension` are added on top of whatever the discovery directories and `config.json` already contribute, for that one session only. Nothing is written to disk and the next launch forgets them. ## Add extra roots in config.json `~/.config/kli/config.json` is an optional file. Three keys control loading: | Key | Type | Effect | |-----|------|--------| | `enabled` | array of names | Force these extensions on, overriding their default. | | `disabled` | array of names | Force these extensions off. | | `extension-dirs` | array of paths | Extra directories to scan, in addition to the two discovery directories. | ```json { "enabled": ["greet"], "disabled": ["noisy-extension"], "extension-dirs": ["/srv/shared/kli-extensions"] } ``` Names are matched case-insensitively, so `"Greet"` and `"greet"` name the same extension. A directory under `extension-dirs` that does not exist is skipped. If `config.json` is missing or malformed, kli warns and proceeds as though it were absent rather than failing to start. ## Which extensions are enabled An indexed extension is installed unless something turns it off. Settle a name by walking these checks in order and taking the first that names it: 1. The active profile's disable list (if a profile is active) turns it off. 2. The active profile's enable list turns it on. 3. `disabled` in `config.json` turns it off. 4. `enabled` in `config.json` turns it on. 5. The extension's own `:autoload` metadata, if it set one. 6. Otherwise, on. To override an extension that ships off by default, add its name to `enabled`. To override an extension that ships on, add it to `disabled`. A profile wins over `config.json`, so a profile's lists can flip either way for that profile only. For profiles, see [Profiles](/kli/config/profiles). ## Reload after an edit In a running session, `/reload` re-reads every extension file on disk: ``` /reload ``` It retracts every installed user extension, clears the indexed registry and the diagnostics from the previous pass, re-indexes from the current files, and installs the enabled set again. An edit to a file takes effect in place, with no rebuild and no restart. kli replies with the list of user extensions it now knows about and their state. Loading is fail-soft: a file that errors warns and is isolated under a `[diagnostics]` entry while every other extension keeps working. Fix the file and run `/reload` again. ## Toggle one extension live `/enable` and `/disable` switch a single extension without touching disk: ``` /enable greet ``` `/enable NAME` installs an indexed extension that is currently off. kli replies `Enabled greet.`, or `greet is already enabled.` if it was already on, or `No such extension: greet.` if no extension by that name was indexed. ``` /disable greet ``` `/disable NAME` retracts an installed extension from the session. kli replies `Disabled greet.`, or `greet is not enabled.` if it was not installed. The file stays on disk, so a later `/reload` or the next session brings the extension back unless `config.json` disables it. ## List what is available `/extensions` reports every indexed extension and whether it is currently installed: ``` /extensions ``` Each line is marked `[enabled]` or `[disabled]`. When no user extensions were found, kli says so. To see which files failed to load, run `/reload`, which appends a `[diagnostics]` line for each failed unit; `/extensions` lists state only. #### Lisp Extension Examples Each section below is one self-contained extension you can drop into `~/.config/kli/extensions/`, load with `/reload`, and use. They cover five contribution kinds: a tool the model can call, a slash command you run, a widget that draws under the prompt, a color theme, and a renderer that restyles a kind of transcript message. Pick the one that matches what you want to add. Every example is a single `.lisp` file holding exactly one `defextension`. Single-file extensions load in the `kli/author` package, where `defextension`, `command`, `reply`, and `rest-arg` are available unqualified. Clause heads like `tool`, `theme`, and `status-slot` are matched by name, so they work unqualified too. Functions you call inside a clause that live in another package must be written with their package prefix; each example below shows the prefixes it needs. For the full grammar and the complete list of contribution kinds, see [Contribution kinds](/kli/extend/lisp-extensions/contribution-kinds) and the [Extension reference](/kli/extend/lisp-extensions/anatomy). After writing any file, run `/reload` in your session, then `/extensions` to confirm it loaded enabled. If a file has an error, `/reload` warns about that one file and keeps every other extension working. ## Add a tool the model can call A tool is a function the model invokes during a turn. Declare it with a name, a description the model reads, a parameter schema, and a `:runner`. This one reverses a string. `~/.config/kli/extensions/reverse-tool.lisp`: ```lisp (defextension reverse-tool (:provides (tool reverse :label "Reverse" :description "Reverse the characters of a string." :parameters '(:object (:text :string)) :runner (lambda (tool parameters context &key call-id on-update) (declare (ignore tool context call-id on-update)) (reverse (kli/ext:tool-parameter parameters :text)))))) ``` - `:parameters` is an `:object` schema. Each entry is `(NAME :TYPE)`; add `:optional t` to make one optional, as in `(:directory :string :optional t)`. - The runner signature `(tool parameters context &key call-id on-update)` is fixed. Declare-ignore what you do not use. - `(kli/ext:tool-parameter parameters :text)` reads one argument by name. - A runner may return a plain string, which kli wraps into a tool result. For a result with structured details or an error flag, return `(kli/ext:make-tool-result :content (list (kli/ext:make-tool-text-content "...")) :error-p t)`. After `/reload`, the `reverse` tool is in the model's tool set and the model can call it. ## Add a slash command A command runs when you type `/name` at the prompt. The handler returns a `(reply ...)` result. This one echoes the system clock. `~/.config/kli/extensions/now.lisp`: ```lisp (defextension now (:provides (command "now" :description "Print the current time." :handler (lambda (command arguments context &key call-id on-update) (declare (ignore command arguments context call-id on-update)) (multiple-value-bind (s m h) (get-decoded-time) (reply (format nil "~2,'0D:~2,'0D:~2,'0D" h m s))))))) ``` - The string after `command` is the name typed after the slash. - The handler signature `(command arguments context &key call-id on-update)` is fixed. - To read free text the user typed after the command name, add `:arguments '(:tail :name)` and call `(rest-arg arguments)`, which returns the tail or `nil`. - `(reply text)` builds the result kli shows. After `/reload`, `/now` prints the time. ## Add a status-line widget A widget draws lines in the footer under the prompt on every frame. Declare it with `widget` and a factory taking `(protocol theme width)` that returns a list of lines. This one shows the current working directory. `~/.config/kli/extensions/cwd-widget.lisp`: ```lisp (defextension cwd-widget (:provides (widget cwd (lambda (protocol theme width) (declare (ignore protocol)) (let ((text (format nil "cwd: ~A" (uiop:getcwd)))) (list (if theme (kli/tui/style:style theme "muted" (kli/text:pad-right text width)) (kli/text:pad-right text width)))))))) ``` - The factory returns a list of strings, one per footer line. Return `nil` for no lines. - `width` is the terminal width. `(kli/text:pad-right text width)` pads a line to fill it. - `theme` is the active theme, or `nil` when none is resolved. `(kli/tui/style:style theme TOKEN text)` colors `text` with a theme token such as `"muted"` or `"accent"`. Guard the no-theme case as shown. - A widget that errors or returns a non-list contributes no lines and is isolated; the rest of the footer keeps drawing. For a one-line value you set imperatively rather than recompute every frame, use a `status-slot` instead: ```lisp (defextension build-status (:provides (status-slot build :initial "build: idle"))) ``` A slot registers a named footer segment seeded with `:initial`. Update it from a command or event handler with `(kli/tui/status:set-status protocol :build "build: passing")`, where `protocol` is `(active-protocol context)`. An empty slot draws nothing. After `/reload`, the footer shows the new line. ## Add a theme A theme is a named color palette. Declare it with `theme` and a theme value built from JSON by `kli/tui/style:load-theme`. The JSON has a `name`, a `vars` map of reusable color values, and a `colors` map from token names to either a hex color or a `vars` key. An empty token value means the terminal default. `~/.config/kli/extensions/solarized.lisp`: ```lisp (defextension solarized (:provides (theme solarized (kli/tui/style:load-theme "{ \"name\": \"solarized\", \"vars\": { \"base\": \"#268bd2\", \"red\": \"#dc322f\" }, \"colors\": { \"accent\": \"base\", \"error\": \"red\", \"text\": \"\", \"mdHeading\": \"base\" } }")))) ``` - `load-theme` accepts a JSON string, as here, or a pathname to a `.json` file. - The `name` in the JSON is how you select the theme; it does not have to match the `defextension` name. - Tokens you omit fall back to the active built-in palette. The full set of token names (such as `accent`, `error`, `mdHeading`, `toolSuccessBg`, `syntaxKeyword`) is in the [Themes reference](/kli/commands/themes). After `/reload`, the theme is registered and available to select. ## Add a message renderer A message renderer replaces how one kind of transcript event draws. Declare it with `message-renderer`, a transcript-event kind to key on, and a function taking `(event theme width)` that returns a list of lines. This one tags every assistant `:message` with a marker line above it. `~/.config/kli/extensions/reply-marker.lisp`: ```lisp (defextension reply-marker (:provides (message-renderer :message (lambda (event theme width) (let* ((text (kli/tui/transcript:event-text event)) (marker "<<< reply") (body (loop for line in (kli/text:wrap-text text width) collect (kli/text:pad-right line width)))) (cons (if theme (kli/tui/style:style theme "accent" (kli/text:pad-right marker width)) (kli/text:pad-right marker width)) body)))))) ``` - The renderer keys on the event kind, `:message` here. It runs for every transcript event of that kind, replacing the default rendering for it. - `(kli/tui/transcript:event-text event)` reads the message text. `event-role` (`:assistant`, `:user`) and `event-kind` are also available; branch on `event-role` inside the function if you want to leave one role untouched by returning the default rendering for it. - Lines are strings sized to `width`. `(kli/text:wrap-text text width)` wraps long text, and `kli/text:pad-right` fills each line. After `/reload`, assistant replies render with the marker. ## The same kinds, in tree These are small on purpose. The same kinds ship in kli's own builtins, where they do production work: - The **tool** kind: the builtin `bash`, `filesystem`, and `lisp` tool extensions. - The **command** kind: the builtin `install` and `settings` commands. - The **theme** kind: the builtin theme extension, with its `:dark` and `:light` palettes. - The **widget**, **status-slot**, and **message-renderer** kinds: contributed by the terminal-UI status and transcript subsystems. For one extension that uses many kinds at once, read cairn: a manifest with a store-opening effect, its model tools, a live context hot-patch, and its own slash commands, each contribution carrying a retractor. ## Next steps - The shared structure under all five — the `defextension` grammar, requirements, metadata, and the imperative `kli-extension` builder: see [Anatomy of an extension](/kli/extend/lisp-extensions/anatomy). - Loading order, project-local extensions, and the `enabled`/`disabled` config: see [Loading and managing extensions](/kli/extend/lisp-extensions/loading-and-managing). - How a running kli installs and retracts these without a restart: see [The live image](/kli/concepts/the-live-image). #### Sharing Extensions This page is the receiving side. To install an extension someone published at a URL, you name the URL and the git object id it is pinned to, confirm two trust cards, and kli loads only bytes that hash to that id. There are two entry points for the same install: - **In a running session**, `/install ` loads the extension live into the current image. - **From a shell or a script**, `kli install ` places it durably for the next session without needing one running. Both run the identical verification and the same two cards; they differ only in whether the code activates immediately or on the next launch. This is a remote *extension* install, not the command that installs kli itself — that is the one-time shell install in [Installation](/kli/cli/installation). For the other side of this exchange — packaging, pinning, and signing an extension to publish — see [Publishing extensions](/kli/extend/publishing-extensions). Nix users have a third option that skips runtime install entirely: bake the extension into the image with [`programs.kli`](/kli/config/nix-module). ## Get the URL and the pin The publisher gives you two things: the URL the extension is served from, and the git object id it is pinned to. For a single-file extension the pin is the git blob id of the file, what `git hash-object ` prints; for a directory extension it is the git tree id over the whole unpacked tree, what `git write-tree` produces. Either way it is an identity no two different contents can share. If the publisher hands you only a URL, ask for the pin; an install with no pin, or with the wrong pin, is refused rather than loaded. A published extension is a single Lisp file or a directory bundle — a multi-file unit packaged as one blob — the same kinds you write as a [Lisp extension](/kli/extend/lisp-extensions). kli detects which shape it fetched and verifies the matching pin. When it loads it gains eval authority in your session, so the pin is what lets you install code you did not write and still know exactly which bytes ran. ## Install in a running session In an interactive session, type: ``` /install https://example.com/path/to/extension.lisp 3f8a1c2e9b7d4f60a51e8c2d9f0b4a7c6e1d3b85 ``` The command needs exactly the URL and the hash, two whitespace-separated tokens. Anything else prints `Usage: /install `. This slash command runs in an interactive session and loads the extension live; to install without a running session, use the [`kli install` command line](#install-from-the-command-line). ## Confirm the consent-to-load card kli shows a first card before fetching anything. It states the plain fact that author-provided Lisp is about to load into the running image with eval authority, names the URL, and shows the hash you pinned: ``` Install from https://example.com/path/to/extension.lisp. This loads author-provided Lisp code into the running image with eval authority, pinned to git 3f8a1c2e9b7d4f60a51e8c2d9f0b4a7c6e1d3b85. ``` A two-row menu opens under it: pick `install` to proceed or `cancel` to stop. `Esc` dismisses with no action. Nothing has been fetched at this point, so cancelling here makes no request and loads nothing. ## Confirm the verification card On the first confirm, kli fetches the bytes from the URL and hashes them as a git blob. If that id does not equal the hash you supplied, verification fails and the install stops with a `verification failed` line naming the URL and reason; no code is placed or loaded. A wrong or missing hash is a hard failure, never a skip. When the bytes match, a second card reports the verified identity and the trust level, then opens the same `install` / `cancel` menu: ``` Verified bytes match git 3f8a1c2e9b7d4f60a51e8c2d9f0b4a7c6e1d3b85. Unsigned: integrity-pinned only. ``` `Unsigned: integrity-pinned only` is the default state: kli verified that the bytes are the ones the hash names, and nothing more. It does not vouch for who wrote them. If you have configured publisher signing keys, this card instead reads `Signed by trusted key ` and a download missing or carrying an untrusted signature is refused at this step. Signing is opt-in and off by default; you turn it on by listing keys in [`trustRoots`](/kli/config/settings#trustroots). Confirm the second card and kli places the verified file under `~/.config/kli/extensions/`, installs it into the running session, and prints `Installed .` The extension's tools, commands, and keybindings are live immediately, with no restart. Picking `cancel` at either card prints `Install cancelled.`; a verified install that fails to index prints `Install of rejected ().` ## Install from the command line `kli install` is the same install as a subcommand of the `kli` program, for a shell or a script rather than a running session: ``` kli install https://example.com/path/to/extension.lisp 3f8a1c2e9b7d4f60a51e8c2d9f0b4a7c6e1d3b85 --yes ``` It takes the URL and the pin as its two positional arguments and runs the identical verification — the git-object pin, then the opt-in signature check when you have configured trust roots. What differs is the result: rather than loading into a running image, it places the verified files durably under `~/.config/kli/extensions/` without activating them, and the next `kli` session — or `kli mcp-serve ` for a served extension like cairn — discovers them from disk. This is the path a headless install or a provisioning script takes. The two trust cards print to stderr. `--yes` (or `-y`) confirms both stages without prompting; without it, an interactive terminal prompts once per stage, and a non-terminal run refuses rather than blocking on a prompt no one can answer. On success the declared extension id prints to stdout, so a script can capture it cleanly, and `Installed .` prints to stderr. The exit code reports the outcome: `0` installed; `2` a malformed invocation, which also prints `Usage: kli install [--yes]`; and `3` a refusal, a cancellation, or a verification failure, in which case nothing was placed. ## What persists A confirmed install is recorded as a pin: its URL, the git tree sha1, and the trust level. Later sessions re-fetch the file from its URL and re-verify it against the same hash before loading. If the bytes no longer match the pin, or a previously signed pin comes back unsigned, kli declines to load it and records the refusal rather than running changed code under your old consent. The pin travels with your session, the verification does not weaken on restore, and you confirm the trust cards once, not every session. ## Related - [Publishing extensions](/kli/extend/publishing-extensions) — the sending side: packaging, pinning, and signing an extension so others can install it. - [`trustRoots`](/kli/config/settings#trustroots) — the setting that turns the signature check from optional into required. - [Lisp Extensions](/kli/extend/lisp-extensions) — what a published extension is and how to write your own. - [Installation](/kli/cli/installation) — installing the kli app, which is a different `/install`. - [Capabilities and fault barriers](/kli/concepts/capabilities-and-fault-barriers) — gating what an installed extension's tools can do, and the opt-in signing keys. - [The Live Image](/kli/concepts/the-live-image) — why an extension can install and run without a restart. #### Publishing Extensions This page is the publishing side. To let someone install your extension with `kli install`, you host the code at a URL and hand out two things: the URL and a **pin** — the git object id of the exact bytes you published. kli refuses to load anything whose content does not hash to that pin, so the pin is what lets a stranger install your code and still know exactly which bytes ran. Signing is an optional layer on top that also proves who published them. If your recipients are Nix users, you can instead distribute the extension as a flake package they bake into their image — see [Distribute as a Nix package](#distribute-as-a-nix-package). For the receiving side — running `kli install` and confirming the trust cards — see [Sharing extensions](/kli/extend/sharing-extensions). ## Decide the shape An extension is published in one of two shapes, and the pin is a different git object id for each. - A **single Lisp file** is the common case: one `.lisp` file, one blob, pinned by its git blob id. Publish the file as-is. - A **directory extension** is a multi-file unit — several source files, a load order, usually an `.asd`. It is published as one *bundle* blob whose pin is the git tree id over its unpacked tree. cairn ships this way. If your extension is a single file, skip to [Publish a single file](#publish-a-single-file). If it is a directory, see [Structure a directory extension](#structure-a-directory-extension) first. ## Publish a single file Host the `.lisp` file at a stable URL that returns its raw bytes. Compute the pin with git: ``` git hash-object extension.lisp ``` That prints the git blob object id — git's sha-1 over the blob header and the file content, the same id git stores the file under. Hand out the URL and this pin. A recipient installs it with: ``` kli install https://example.com/extension.lisp ``` kli fetches the bytes, hashes them the same way, and refuses the install if the result does not equal the pin. Serve the file over HTTPS from a location whose bytes do not change under the same URL; if you edit the file, its pin changes and you publish a new pin. ## Structure a directory extension A directory becomes a single loadable unit when it carries **either** an `extension.lisp` marker file **or** exactly one `.asd` system definition. kli then treats the whole top-level directory as one extension and does not descend into it. Load order is decided as follows: - With an `.asd`, the `:components` order in the system definition governs. kli loads the unit by running `asdf:load-asd` then `asdf:load-system`, so ASDF's declared order is honored exactly. - Without an `.asd`, the convention governs: `package.lisp` loads first, `extension.lisp` loads last, and the remaining files load in alphabetical order. If your files have dependencies that alphabetical order would break — a store file that must load before the model that uses it, say — author an `.asd` and declare the order. A minimal one: ```lisp (defsystem "my-extension" :serial t :components ((:file "src/package") (:file "src/store") (:file "src/model") (:file "src/extension"))) ``` `:serial t` loads the components in the listed order. The unit is rooted at the directory holding the `.asd`; the marker and package conventions from [Loading and managing extensions](/kli/extend/lisp-extensions/loading-and-managing) still describe how a local directory is discovered. ## Bundle a directory extension A directory is published as one **bundle** blob: a JSON envelope that carries every file, so it fetches, verifies, and signs exactly like a single file does. The envelope is: ```json { "format": "kli-dir-bundle-v1", "files": { "my-extension.asd": "", "src/package.lisp": "", "src/store.lisp": "" } } ``` Each key is a path relative to the extension root; each value is the base64 of that file's raw bytes. Sort the paths and emit compact JSON so the bytes are a deterministic function of the source — the same input always produces the same bundle, and therefore the same pin. The pin for a bundle is the **git tree id** over the unpacked files, which is what `git write-tree` produces for the same file set: ``` git init -q tree && cd tree # copy the extension's files into place, preserving relative paths git add -A && git write-tree ``` That id verifies the whole tree at once. Host the bundle blob at a URL and hand out the URL and this pin; kli detects the envelope, unpacks it, verifies the tree, and places the directory unit under the recipient's extensions directory. ## Sign a release The pin proves *integrity* — that the bytes are the ones the pin names. A signature proves *authenticity* — that you published them. Signing is opt-in: a recipient who has configured no trust roots verifies the pin only, and one who trusts your key requires a valid signature from it or refuses the install. See [`trustRoots`](/kli/config/settings#trustroots) for the recipient side. To sign, mint an ed25519 keypair, keep the private seed secret, and publish the public key as hex. Sign the **raw bytes of the published artifact** — the exact file or bundle blob you serve — producing a detached ed25519 signature over those bytes (ed25519 hashes the message internally, so the bytes are signed unhashed). Host the signature next to the artifact at `.sig`: for `extension.lisp` that is `extension.lisp.sig`. kli fetches the signature by this convention only when the recipient has configured trust roots. Because the signature is over the served bytes, re-sign whenever you re-serialize the artifact. Rotating your key means publishing the new public key; recipients update their trust roots to match. ## What you hand out A published extension is fully described by: - the **URL** the artifact is served from, - the **pin** — `git hash-object` for a single file, `git write-tree` for a directory bundle, - your **public key** hex, if you signed it, for recipients to add to their trust roots. A checksums file listing the artifact's sha-256 is a courtesy for out-of-band verification, but the pin, not the checksum, is what kli enforces. With those in hand, a recipient runs `kli install ` and, for a directory extension like cairn, serves it afterward with `kli mcp-serve `. ## Distribute as a Nix package The URL-and-pin channel above is per-user and runtime: a recipient runs `kli install` and the extension lands in their config directory. The other way to hand out an extension is as a **Nix package** a recipient bakes into their kli image with [`programs.kli`](/kli/config/nix-module). Nothing is fetched or pinned at install time — the extension is compiled into the image, and trust rides on the recipient's flake inputs and Nix hashing. This is the channel Nix users prefer, and it is how cairn ships. You can offer both from one source: a URL-and-pin build for runtime installs, and a flake package for image builds. To distribute this way, expose your extension as a flake package that is a **buildLisp library** carrying two `passthru` fields: - `passthru.name` — the extension id. - `passthru.manifestSymbol` — the package-qualified symbol naming your manifest, the `*…-extension-manifest*` variable [`defextension`](/kli/extend/lisp-extensions/anatomy) binds. kli fails the image build if the compiled result does not export it, so a mislabelled package cannot ship silently. kli composes that package into the image as a build dependency, so it must be built with the same `buildLisp` kli uses. The plug-and-play way to get `buildLisp` and a batteries-included set of Common Lisp libraries is the public **cl-deps** flake — the exact dependency set kli itself is built from: ```nix { inputs.cl-deps.url = "github:kleisli-io/cl-deps"; } ``` `cl-deps.lib..buildLisp` builds your library, and `cl-deps.lib..lisp.*` supplies common dependencies. A minimal extension package: ```nix cl-deps.lib.${system}.buildLisp.library { name = "greet"; srcs = [ ./src/package.lisp ./src/greet.lisp ]; deps = [ cl-deps.lib.${system}.lisp.alexandria ]; passthru = { name = "greet"; manifestSymbol = "greet:*greet-extension-manifest*"; }; } ``` Expose that as `packages..default`, and a recipient adds `inputs.greet.packages.${system}.default` to `programs.kli.extensions`. If cl-deps does not already package a Lisp library you depend on, fork it and add the definition — its README shows the one place to declare a new library, and native C dependencies use the `native` attribute. cl-deps is one of several ways to build Common Lisp under Nix; you are free to use another for your own code, but the package you hand to `programs.kli.extensions` must be a buildLisp library so it can compile into the image. ## Related - [The programs.kli module](/kli/config/nix-module) — the recipient side of the Nix-package channel: baking your extension into an image. - [Sharing extensions](/kli/extend/sharing-extensions) — the receiving side: `kli install`, the two trust cards, and what persists. - [Loading and managing extensions](/kli/extend/lisp-extensions/loading-and-managing) — how a directory unit is discovered and ordered on disk. - [Write your first Lisp extension](/kli/extend/lisp-extensions/write-your-first) — authoring the extension you are publishing. - [`trustRoots`](/kli/config/settings#trustroots) — the recipient-side setting that turns signature verification on. ## Reference ### CLI & Install #### Installation kli installs on Linux and macOS. The canonical route is a prebuilt binary fetched by the install script; Nix and a from-source build are also supported. ## Install command ```sh curl -fsSL https://kli.kleisli.io | sh ``` The script is served at the site root. There is no `/install` route. The script detects the platform, downloads the matching release tarball, verifies the checksum when one is published, extracts the payload, and writes a `kli` wrapper. When the install directory's `bin` is not on `PATH`, it prints the `export PATH=…` line to add. ## Installer environment variables Set these in the environment of the `curl … | sh` invocation. | Variable | Default | Effect | |---|---|---| | `KLI_VERSION` | latest published release | Pin a version tag, e.g. `v0.1.0`. When unset, the script resolves the latest tag from the GitHub releases API. | | `KLI_INSTALL_DIR` | `~/.local`; `/usr/local` when run as root | Install root. The payload goes under `/lib/kli` and the wrapper at `/bin/kli`. | | `KLI_DOWNLOAD_BASE` | the GitHub release for the resolved version | Override the download origin wholesale: a private mirror, an air-gapped copy, or a local server. When set, version resolution is skipped and the artifact and `checksums.txt` are fetched from this base. | A pinned-version invocation: ```sh KLI_VERSION=v0.1.0 curl -fsSL https://kli.kleisli.io | sh ``` ## On-disk layout The installer lays out the payload under `/lib/kli` and puts a small wrapper on `PATH`. | Path | Contents | |---|---| | `/lib/kli/bin/kli` | The relocatable image launcher. It self-locates from its own directory, sets the data directory and dynamic-library search path, and execs the image. | | `/lib/kli/lib/` | Bundled runtime libraries (dynamic loader and shared objects) the image loads. | | `/lib/kli/share/kli/` | Runtime resource roots (built-in skills, TUI themes). | | `/bin/kli` | The wrapper on `PATH`. It execs `/lib/kli/bin/kli` by absolute path. | The wrapper invokes the launcher by absolute path, not a symlink: the launcher roots its lookups off its own directory, so a symlinked entry would mis-root. Checksum verification is best-effort. The script fetches `checksums.txt` from the same base and compares against `sha256sum` or `shasum`. A mismatch aborts the install. When neither tool is present and no checksum file is published, verification is skipped. Re-running the script over an existing install removes the previous `bin`, `lib`, `share`, and `VERSION` under `/lib/kli` before extracting, so an upgrade is clean. ## Supported targets Prebuilt binaries are published for three targets: | OS | Architecture | Prebuilt binary | |---|---|---| | Linux | x86_64 | yes | | Linux | aarch64 (arm64) | yes | | macOS | aarch64 (Apple Silicon) | yes | | macOS | x86_64 (Intel) | no | Intel macOS has no prebuilt binary. Install via Nix or from source. ## Nix The flake at `github:kleisli-io/kli` builds for `x86_64-linux`, `aarch64-linux`, and `aarch64-darwin`. Two entry points run or install the program: ```sh nix run github:kleisli-io/kli nix profile install github:kleisli-io/kli ``` | Output | Contents | |---|---| | `overlays.default` | Adds `kli` to a nixpkgs overlay; the package is then `pkgs.kli`. | | `packages..kli` (and `.default`) | The `kli` program derivation. | The overlay and package, wired into a NixOS or home configuration: ```nix { inputs.kli.url = "github:kleisli-io/kli"; # nixpkgs.overlays = [ inputs.kli.overlays.default ]; # environment.systemPackages = [ pkgs.kli ]; } ``` That installs kli itself. To bake extensions and settings into the image, use [the `programs.kli` module](/kli/config/nix-module) instead. ## From source A source build needs SBCL and [qlot](https://github.com/fukamachi/qlot). The external Common Lisp systems are pinned in `qlfile`. ```sh qlot install qlot exec sbcl --script build.lisp ./bin/kli ``` `qlot install` resolves the pinned systems. `build.lisp` loads the `kli` system and dumps a standalone image to `bin/kli` in the working directory. #### Environment Variables kli reads environment variables in two places: the installer script (`curl -fsSL https://kli.kleisli.io | sh`) reads them once, while it downloads and unpacks a release; the running app reads them on each launch. The two sets do not overlap. A variable read by the installer has no effect once kli is installed, and a variable read by the app has no effect during installation. ## Provider credentials The app reads provider API keys from the environment at the moment it resolves a credential, not at boot. Changing the variable's value changes the credential the next time a request is made; there is no cached copy to clear. Both variables back a credential reference that names the variable rather than storing its value. The reference is what kli persists; the secret stays in the environment. A provider whose default credential names a variable that is unset or empty is treated as unavailable until the variable holds a non-empty value, or until you register a different credential with `/auth`. | Variable | Read by | Default | Effect | |---|---|---|---| | `ANTHROPIC_API_KEY` | app | unset | API key for the `anthropic` provider (the Anthropic Messages API). Resolved live on each request. | | `OPENAI_API_KEY` | app | unset | API key for the `openai` provider (the OpenAI Responses API). Resolved live on each request. | The `openai-codex` provider authenticates through OAuth and reads no environment variable. A `compatible` provider (a user-defined OpenAI-compatible endpoint declared in `~/.config/kli/providers.json`) reads the variable named by that entry's `key-env` field; the field is per-provider, so the variable name is whatever you set it to. See [Connect a provider](/kli/guides/connect-a-provider) for registering credentials. ## Boot profile | Variable | Read by | Default | Effect | |---|---|---|---| | `KLI_PROFILE` | app | `interactive-terminal` | Names the boot profile to install at launch. | The app resolves the boot profile in order: the `--profile` flag, then `KLI_PROFILE`, then the `profile` key in `settings.json`, then the built-in default `interactive-terminal`. The first source that names a profile wins, so `KLI_PROFILE` overrides the settings key but yields to an explicit `--profile`. A name that resolves to neither a built-in nor a declared data profile falls back to the default and records a boot diagnostic. See [Profiles](/kli/concepts/profiles). ## Docs origin | Variable | Read by | Default | Effect | |---|---|---|---| | `KLI_DOCS_BASE` | app | `https://docs.kleisli.io` | Origin the `kli docs` subcommand fetches documentation from. Point it at a private mirror or a local docs server; a trailing slash is ignored. | | `KLEISLI_BASE_URL` | app | unset | Fallback docs origin for `kli docs`, used only when `KLI_DOCS_BASE` is unset. | `kli docs` resolves the origin in order: `KLI_DOCS_BASE`, then `KLEISLI_BASE_URL`, then the default. The subcommand always fetches the `kli` project's docs. See [Reading the docs](/kli/cli/docs). ## Installer variables The installer reads these once, before download. They take effect when set in the same command that runs the installer, for example `KLI_VERSION=v0.1.0 curl -fsSL https://kli.kleisli.io | sh`. | Variable | Read by | Default | Effect | |---|---|---|---| | `KLI_VERSION` | installer | latest release | Pins the version to install, as a release tag (for example `v0.1.0`). When unset, the installer queries the GitHub API for the latest release tag. Ignored when `KLI_DOWNLOAD_BASE` is set. | | `KLI_INSTALL_DIR` | installer | `~/.local` (`/usr/local` when run as root) | Install root. The binary lands in `/lib/kli` and a launcher wrapper in `/bin/kli`. | | `KLI_DOWNLOAD_BASE` | installer | GitHub release URL | Origin to download the release artifact and `checksums.txt` from, instead of the GitHub release: a private mirror, an air-gapped copy, or a local server. When set, version resolution is skipped and `KLI_VERSION` is ignored. | The installer downloads `kli--.tar.gz` from the resolved origin and verifies it against `checksums.txt` when that file is present. The download base is a directory URL; the installer appends the artifact and checksum filenames to it. See [Installation](/kli/cli/installation) for platform coverage and the from-source path. #### Reading the Docs `kli docs` prints kli documentation straight to your terminal, fetched live as Markdown from the docs site. It always addresses the kli project, and what it prints is whatever the published docs currently say: there is no copy bundled with your installed kli, so the text never goes stale. ## Subcommands | Invocation | Effect | |---|---| | `kli docs` | Print the kli docs index — the list of pages. | | `kli docs
/` | Print one page as Markdown, e.g. `kli docs extend/lisp-extensions/anatomy`. | | `kli docs search ` | Print ranked search hits for ``, each with the page path to fetch. | | `kli docs help` (`--help`, `-h`) | Print usage and exit. | A page path is the part of a docs URL after the project, with no leading slash and no `.md` suffix. The page at `docs.kleisli.io/kli/config/capabilities` is `kli docs config/capabilities`. Search hits print their paths in the same form, so a hit feeds straight back into `kli docs `. ## Where it fetches from `kli docs` reads from `https://docs.kleisli.io` by default. The project is always `kli`; the subcommand never addresses another project's docs. | Variable | Default | Effect | |---|---|---| | `KLI_DOCS_BASE` | `https://docs.kleisli.io` | Origin to fetch the index, pages, and search from instead of the default — a private mirror or a local docs server. A trailing slash is ignored. | | `KLEISLI_BASE_URL` | unset | Honoured as a fallback origin when `KLI_DOCS_BASE` is unset. | The docs are fetched on demand, so they track the published site rather than the version of kli you have installed. A failed fetch — an unknown path, an unreachable origin, a non-200 response — reports the problem on standard error and exits non-zero, leaving standard output empty. ### Commands, Tools & TUI #### Slash Commands A line typed at the kli prompt that begins with `/` is a slash command. The first word names the command; the rest is its argument tail. This page lists every command kli registers, grouped by area. Commands come from extensions, so the set is not fixed: a profile that omits an extension omits its commands, and a loaded extension can add its own. Two families are registered dynamically from files on disk — prompt templates and skills (see [Prompt and skill commands](#prompt-and-skill-commands)). Run `/commands` to see what is registered in the current session, and `/help ` for one command's details. Most commands report their result as a system line in the transcript. Commands marked below as menu-backed open a selection menu in the terminal UI when run with no argument; run with an argument, or run outside the terminal UI, they act directly and print text. ## Basic | Command | Effect | | --- | --- | | `/commands` | List every registered command. | | `/help [command]` | Show the command list, or details for one command. | | `/clear` | Clear the terminal display. The conversation is unchanged. | | `/reset` | Start a new conversation in the current session, clearing history. | | `/redraw` | Repaint the terminal display. | | `/quit` | Stop kli and exit. | `/clear` and `/redraw` act only on the display; the model never sees them. ## Session A session is the durable record of a conversation: its history, name, model selection, and stored file. These commands inspect and manipulate it. See [Sessions](/kli/concepts/sessions-as-a-tree) for the model. | Command | Effect | | --- | --- | | `/name [text]` | Set the session display name to `text`, or show the current name. | | `/session` | Show the active session: id, file, model, and token count. | | `/resume [selector]` | List stored sessions; with a selector, resume the matching one. Menu-backed. | | `/resume delete ` | Delete the matching stored session. The active session cannot be deleted. | | `/compact [instructions]` | Summarize the history into a shorter context, optionally focused by `instructions`. | | `/rewind [n]` | Step the conversation back `n` user turns (default 1), branching the session. Menu-backed. | | `/branches` | Show the tree of sessions created by rewind-branching, and switch between them. Menu-backed. | A bare `/resume` selector matches against a session id, name, or message preview; an unambiguous match resumes, several matches re-list. `/compact` and `/rewind` are refused while a turn is running. ## Context The agent context is the projected message list sent to the model. These commands stage edits to it, then commit or discard them as a set. See [Context lens](/kli/guides/inspect-and-edit-context). | Command | Effect | | --- | --- | | `/context inspect` | Show the context epoch, projected message count, and staged patches. | | `/context stage append ` | Stage a patch appending a user message with `text`. | | `/context stage remove ` | Stage a patch removing the message at `index`. | | `/context stage replace ` | Stage a patch replacing the message at `index` with `text`. | | `/context diff` | Show staged patches without applying them. | | `/context commit` | Apply all staged patches to the context, advancing the epoch. | | `/context revert` | Discard all staged patches. | Each subcommand is a separate registered command resolved from the `/context ` prefix. Staging never changes the context; only `/context commit` does. ## Model and providers These commands choose the model, set reasoning effort, and manage provider credentials. See [Connect a provider](/kli/guides/connect-a-provider). | Command | Effect | | --- | --- | | `/model [provider/model [level]]` | Select the current model, optionally with a `reasoning-effort` level; bare, show and list. Menu-backed. | | `/models [search]` | List auth-available models, optionally filtered by `search`; option-capable models show compact `options ...` markers. | | `/providers` | List model providers with their auth status and model counts. | | `/thinking [level]` | Set the `reasoning-effort` option for the selected model, or show the current level. Menu-backed. | | `/auth` | Show registered providers and credential references. | A model reference is `provider/model`, for example `anthropic/claude-sonnet-4-5`. The `/thinking` level is one of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, accepted only for a model that declares `reasoning-effort`. kli ships the providers `anthropic`, `openai`, `openai-codex`, and `compatible` (user-defined OpenAI-compatible endpoints). `/auth` takes a subcommand to register or remove credentials: | Command | Effect | | --- | --- | | `/auth env ` | Read the provider's credential from environment variable `ENV_VAR`. | | `/auth key ` | Store `KEY` as the provider's static API key. | | `/auth login ` | Start an OAuth login and print the authorization URL. | | `/auth code ` | Complete the pending OAuth login. | | `/auth logout ` | Forget the provider's stored credential. | `/auth` is hidden from the model, since its tail can carry a raw API key. ## Configuration | Command | Effect | | --- | --- | | `/settings` | Show config directories, the global and project settings files, registered resource kinds, and the merged settings. | kli merges settings from `~/.config/kli/settings.json` and `/.kli/settings.json`, project over global. See [Settings](/kli/config/settings). ## Profiles A profile is a named set of extensions and settings. See [Profiles](/kli/config/profiles). | Command | Effect | | --- | --- | | `/profile [name]` | List profiles, or live-switch to profile `name`. | A live switch installs the extensions the target profile wants and retracts the ones it does not. A profile whose builtin base differs from the running one cannot be switched live; kli reports the `--profile` flag to restart with instead. ## Extensions These commands control user extensions discovered from `~/.config/kli/extensions/` and `/.kli/extensions/`, plus any passed with `--extension`. See [Extensions](/kli/extend/lisp-extensions). | Command | Effect | | --- | --- | | `/extensions` | List discovered user extensions and whether each is enabled. | | `/enable ` | Install (enable) a discovered extension by id. | | `/disable ` | Retract (disable) an installed extension by id. | | `/reload` | Retract, re-discover, and re-install user extensions, picking up edits on disk. | | `/uninstall ` | Remove a runtime-installed extension and its pin. Nix-declared extensions cannot be uninstalled this way. | ## App | Command | Effect | | --- | --- | | `/install ` | Install a remote extension from `url`, pinned to the git tree object `git-tree-sha1`. | `/install` here is the in-session command to add a remote **extension**. It is distinct from installing the kli application itself, which is `curl -fsSL https://kli.kleisli.io | sh` and has nothing to do with this command. The in-session `/install` runs a two-step consent flow in the terminal UI: it shows a trust card for the URL and pin, verifies the artifact against the pinned git tree sha1 without loading it, then installs on a second confirmation. The pin makes the install reproducible: the same `git-tree-sha1` always resolves the same code. `/install` requires the terminal UI. ## Diagnostics | Command | Effect | | --- | --- | | `/bash ` | Run `command` in a shell and show its output. | | `/eval
` | Evaluate a Common Lisp `form` in the running image and show the result. | | `/observability` | Report the observability sink: whether it is enabled, its file path, event filter, and event count. | `/bash` and `/eval` run the same tools the agent uses, invoked by hand. The observability sink is configured under the `observability` section of `settings.json`; `/observability` reports its live state. ## Prompt and skill commands Two command families are registered from files at startup, so their names depend on what is on disk. | Command | Effect | | --- | --- | | `/