# Kleisli.IO Documentation — Full Content # cairn ## Learn ### Quickstart #### Install cairn cairn ships as an extension for kli, the runtime it was built for, and kli serves it to any client that speaks the Model Context Protocol. In this tutorial you install cairn one of two ways, point an agent at it, and confirm the tool surface is live. By the end you have a working cairn that an agent can plan in and resume into. You do not have to adopt kli as your main agent to use cairn; any MCP client works. You need kli built and a project directory to work in. The directory you launch from selects the project whose task graph cairn opens, so run these steps inside the repository you want the graph to track. ## Install with kli There are two ways to install. Bake cairn into the kli image declaratively, or install it at runtime from a running session. Pick one. ### Declaratively Take cairn as a flake input and name it in your kli configuration. kli's Home Manager and NixOS modules compile the listed extensions into the image. cairn needs SQLite on its library path, because its observation store is built on SQLite FTS5. ```nix { inputs.cairn.url = "github:kleisli-io/cairn"; } ``` ```nix programs.kli = { enable = true; extensions = [ inputs.cairn.packages.${system}.default ]; blessedNativeLibs = [ pkgs.sqlite ]; }; ``` Rebuild, and cairn is in the image. Anything baked in this way stays put until you edit your configuration and rebuild again. ### At runtime Each cairn [release page](https://github.com/kleisli-io/cairn/releases) carries the exact install command for that version: the url of the published bundle and the git tree hash it is pinned to. For v0.1.1: ```text kli install https://github.com/kleisli-io/cairn/releases/download/v0.1.1/cairn.bundle 1b92b2928de8de87711c299d7353f1e0b602068e ``` kli fetches the bundle, recomputes the tree hash over its contents, and refuses to install on a mismatch, so what loads is exactly the bytes the release names. It shows what it is about to add and asks first; pass `--yes` in scripts and other non-interactive runs. The install is durable: the next `kli` or `kli mcp-serve` launch finds cairn without installing again. The same install runs from inside a kli session as `/install `, loading cairn live with no rebuild. The companion commands manage what you have installed: `/extensions` lists them, `/enable` and `/disable` toggle one, and `/uninstall` removes a runtime-installed extension. cairn releases are also signed. To require the signature rather than the pin alone, add the Kleisli.IO release key to [`trustRoots`](/kli/config/settings#trustroots); the key is published on each release page and committed at [`release/trust/cairn-release.pub`](https://github.com/kleisli-io/cairn/blob/main/release/trust/cairn-release.pub). [Sharing extensions](/kli/extend/sharing-extensions) walks the full trust flow. To load a local build for a single run instead of installing it, start kli with `--extension`: ```text kli --extension ``` That extension lives only for the life of that run. ## Serve to any MCP client The same kli, built with cairn, serves the extension to any other client over stdio. One command does it: ```text kli mcp-serve cairn ``` Wire a client to that command. Claude Code, Claude Desktop, and Cursor all read the same `mcpServers` block, so a single server entry connects any of them: ```json { "mcpServers": { "cairn": { "command": "kli", "args": ["mcp-serve", "cairn"] } } } ``` The client launches kli in your project directory, and that working directory picks the project whose task graph cairn opens. Once connected, the client lists all fourteen of cairn's tools, with the bundled workflow prompts and the `cairn-method` skill exposed as MCP prompts and resources. The slash commands, the per-turn context injection, and the compaction folding stay behind, because kli provides those as the host rather than over the tool protocol. That is why kli is the fuller home for cairn. See [the mcp-serve reference](/cairn/cli/mcp-serve) for the full client matrix, exactly what travels, and the exposed surface in full. ## Confirm it is live Connect your agent and have it call one read-only tool to confirm cairn answers. `task_bootstrap` orients on the current task in a single call; in a fresh project with no current task set, it answers with a clear message instead of crashing: ```text No task to bootstrap; pass task_id or select a task first. ``` That message comes back as a tool result, not a protocol error, and it means the tool surface is wired and the graph is empty, which is exactly the state a first session starts from. From here, run [your first cairn session](/cairn/get-started/your-first-cairn-session) to create a task, record an observation, write a handoff, and bootstrap back into the work. #### Your first cairn session This tutorial walks cairn's continuity loop once so you can see how your agent keeps work alive across a reset. The agent creates a task, records an observation on it, scaffolds a handoff, then resumes that task in a fresh session with one call. Those are the three writes that make up the heartbeat: `task_create`, `observe`, and `handoff`, all read back with `task_bootstrap`. The returned text after each call is explained, so you can tell what the agent reads back. cairn has to be installed and reachable over MCP first; if it is not, see [Install cairn](/cairn/get-started/install-cairn). Each call below is shown the way the agent issues it over MCP, and the text after it is what cairn sends back. ## Create a task A task is a node in the graph: a slug-addressed unit of work that carries a status, a description, edges, metadata, and its observations. The agent creates one by calling `task_create` with a `name`: ```text task_create(name="wire up the export endpoint") ``` cairn mints a slug from the name, stamps today's UTC date as its namespace, and returns: ```text Created 2026-06-22-wire-up-the-export-endpoint. ``` The date prefix is system-owned, so the agent passes a plain name and cairn handles the namespacing. `task_create` also adopts the new task as the [current task](/cairn/concepts/the-current-task-pointer), but only when none is set yet. Nothing was selected before this call, so the agent is now pointed at `2026-06-22-wire-up-the-export-endpoint`, and the writes that follow act on it without naming it again. ## Record an observation An observation is the cheapest write cairn has, and the heartbeat of the loop: a freeform note the agent appends to a task as it works. It records one with `observe`: ```text observe(text="export route returns 500 when the date filter is absent; needs a default range") ``` The note writes against the current task and returns: ```text Observed on 2026-06-22-wire-up-the-export-endpoint. ``` The agent leans on `observe`. It does not move the current pointer and it never blocks, so leaving a trail of what the agent found, decided, and ruled out costs almost nothing, and that trail is what a later session reads back. To write against a different task without switching, the agent passes `task_id`. For the discipline behind the heartbeat, see [The cairn-method](/cairn/concepts/the-cairn-method). ## Write a handoff A handoff is a resumable summary of where a task stands, and the `summary` is its load-bearing field. The agent scaffolds one with `handoff`: ```text handoff(summary="export endpoint half-built; 500 on missing date filter, default range still TODO") ``` cairn writes a skeleton note (frontmatter, a state snapshot, and empty sections to overwrite with the rich body), records the event, and returns the path it wrote to: ```text Handoff scaffolded for 2026-06-22-wire-up-the-export-endpoint at /…/2026-06-22_14-03-09_export-endpoint-half-built.md ``` There is no trailing period after the path. The tool is deterministic: it lays down a skeleton and returns the path, nothing more. It never drives an authoring turn; that is what the `/handoff` slash command does, and a person types that one. See [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) for the tool-versus-command split. ## Resume Close the MCP client and reopen it. The graph survives the reset; the conversation does not. That is the whole point: the plan lives outside the chat, so a fresh session reloads it. In the new session the current pointer is empty, so the agent names the task explicitly and orients on it with `task_bootstrap`: ```text task_bootstrap(task_id="2026-06-22-wire-up-the-export-endpoint") ``` One call returns the task's computed state and its open handoffs: ```text 2026-06-22-wire-up-the-export-endpoint [open] recent: - export route returns 500 when the date filter is absent; needs a default range handoffs: - export endpoint half-built; 500 on missing date filter, default range still TODO (/…/2026-06-22_14-03-09_export-endpoint-half-built.md) ``` The observation and the handoff summary are both back, reloaded into a session that knew nothing a moment ago. `task_bootstrap` records no event; it is a read. Because the agent passed `task_id`, the call switched the current pointer to that task, so the writes it makes next land there. For the no-id and unset-pointer cases, see [The current-task pointer](/cairn/concepts/the-current-task-pointer). That is the full heartbeat: the agent created a task, left an observation, scaffolded a handoff, and resumed the whole thing from a clean session with one call. From here, [Plan and resume](/cairn/get-started/plan-and-resume) builds a small plan as a graph of phases and runs the same loop across it, and [The cairn-method](/cairn/concepts/the-cairn-method) explains the discipline this heartbeat is part of. #### Plan and resume This tutorial turns a single task into a small plan, works one phase, and resumes the plan from cold, so you can see how your agent holds a multi-step plan together across a reset. The agent forks three phases off a root task, orders them with `depends-on` edges, asks cairn which phase is ready, marks a phase `completed`, watches the ready set move, then bootstraps back into the plan as a fresh session would. By the end the plan lives in the graph, not in the conversation, so a context reset reloads it instead of losing it. You need cairn installed and reachable over MCP, and your agent should have run the [bootstrap-observe-handoff loop](/cairn/get-started/your-first-cairn-session) at least once. This tutorial assumes you know what a task, an observation, and a handoff are; if not, read [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) first. Each call below is shown the way the agent issues it over MCP, and the text after it is what cairn returns. ## Create the root task A plan in cairn is a root task with phases hanging off it, not a markdown file. The agent creates the root first: ```text task_create(name="ship the export feature") ``` ```text Created 2026-06-22-ship-the-export-feature. ``` The slug is date-prefixed and minted from the name. `task_create` adopts the new task as the [current task](/cairn/concepts/the-current-task-pointer) because none was set, so every following call that omits `task_id` acts on it. ## Fork the phases A phase is a child task joined to its parent by a `phase-of` edge. `task_fork` mints the child, records both `task.create` and `task.fork`, and, unlike `task_create`, always switches the current pointer to the new child. The parent argument is `from`. When the agent omits it, `task_fork` forks from the current task. Here it forks three phases, passing `from` so each hangs off the root rather than off the previous child: ```text task_fork(name="design the export schema", from="2026-06-22-ship-the-export-feature") ``` ```text Forked 2026-06-22-design-the-export-schema from 2026-06-22-ship-the-export-feature (phase-of). ``` ```text task_fork(name="implement the writer", from="2026-06-22-ship-the-export-feature") ``` ```text Forked 2026-06-22-implement-the-writer from 2026-06-22-ship-the-export-feature (phase-of). ``` ```text task_fork(name="add the download button", from="2026-06-22-ship-the-export-feature") ``` ```text Forked 2026-06-22-add-the-download-button from 2026-06-22-ship-the-export-feature (phase-of). ``` The default `edge_type` is `phase-of`, so it was not passed. **A fork with no `from` and no current task fails** with `No parent task; pass from or select a task first.` The current pointer now sits on the last child forked. That is fine; the next steps address tasks by slug, which never disturbs the pointer. ## Order the phases The phases are siblings under one parent. Nothing yet says the writer waits on the schema, or that the button waits on the writer. A `depends-on` edge says exactly that. `task_link` draws a typed edge from a source task to a target; the agent passes `task_id` to set the source explicitly rather than rely on wherever the pointer happens to be. The writer depends on the schema: ```text task_link(task_id="2026-06-22-implement-the-writer", target_id="2026-06-22-design-the-export-schema", edge_type="depends-on") ``` ```text Linked 2026-06-22-implement-the-writer -> 2026-06-22-design-the-export-schema (depends-on). ``` The download button depends on the writer: ```text task_link(task_id="2026-06-22-add-the-download-button", target_id="2026-06-22-implement-the-writer", edge_type="depends-on") ``` ```text Linked 2026-06-22-add-the-download-button -> 2026-06-22-implement-the-writer (depends-on). ``` `edge_type` is required for `task_link` and must be one of `phase-of`, `depends-on`, or `related`. `phase-of` is the structural backbone the forks already built; `depends-on` is lateral ordering between siblings. The plan is now described: three phases, with a chain of dependencies running schema → writer → button. ## Ask what is ready The frontier is the ready subset of a plan: the phases that are not done and whose dependencies are all settled. cairn computes it; the agent does not work it out by hand. The agent sets the pointer back to the root first, because `plan-frontier` is current-scoped: it reads the phases of the current task and keeps the ones with no unsettled `depends-on` target. ```text task_bootstrap(task_id="2026-06-22-ship-the-export-feature") ``` That orients on the root and makes it current, which is what the `plan` and `plan-frontier` views read from. Now the agent queries the frontier. The query argument is a single TQ form, and a named view is run by wrapping its name as `(query "plan-frontier")`: ```text task_query(query="(query \"plan-frontier\")") ``` ```text 1 task: - 2026-06-22-design-the-export-schema (active) obs=0 edges=2 ``` Only the schema phase comes back. The writer is blocked behind the schema and the button behind the writer, so neither is ready yet. The `obs=0 edges=2` suffix is enrichment the `plan-frontier` view carries: an observation count and an edge count. Here that edge count is the one `phase-of` link up to the root plus the one incoming `depends-on` from the writer. ## Complete a phase and re-query Once the schema work is done, the agent marks the phase complete. `task_update_status` takes a `status` from the closed set `open`, `active`, `completed`, `abandoned`, `blocked`, and is idempotent. ```text task_update_status(task_id="2026-06-22-design-the-export-schema", status="completed") ``` ```text 2026-06-22-design-the-export-schema is now completed. ``` The agent asks the frontier again. The root is still current, so no re-orientation is needed: ```text task_query(query="(query \"plan-frontier\")") ``` ```text 1 task: - 2026-06-22-implement-the-writer (active) obs=0 edges=3 ``` The frontier moved. The schema dropped out because it is settled, and the writer surfaced because its one dependency is now `completed`. The button is still held back behind the writer. The writer's `edges=3` counts its `phase-of` link to the root, its outgoing `depends-on` to the schema, and the incoming `depends-on` from the button. No plan document was edited; one status changed and the ready set recomputed from the graph. This is how the agent finds the next thing to do without re-reading the whole plan: [Views](/cairn/reference/views) documents the other built-in views, `plan`, `leaf-tasks`, and `stale-phases`, that answer related questions. ## Resume the plan in a new session Now simulate what happens after a context reset, a `/clear`, or a colleague's agent picking up the work tomorrow. The plan is in the graph; one call reloads it. `task_bootstrap` returns computed state, neighbors, open handoffs, and recent observations, and records no event, so orienting never mutates the log. ```text task_bootstrap(task_id="2026-06-22-ship-the-export-feature") ``` ```text 2026-06-22-ship-the-export-feature [active] children: 2026-06-22-add-the-download-button, 2026-06-22-design-the-export-schema, 2026-06-22-implement-the-writer ``` The whole plan came back from the slug alone: the root, its status, and the three phases hanging off it as children. The `phase-of` backbone is what the `children:` line reports; the lateral `depends-on` edges live between the phases, so they surface when the agent queries the frontier rather than in the root's neighbor readout. From here the agent re-runs `(query "plan-frontier")` to find the writer waiting, and the work resumes with no memory of the previous session required. That is the cairn method end to end: one status change moved the frontier from the schema to the writer, and a single `task_bootstrap` reloaded the whole plan from the slug. To go deeper, read [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) for the readiness rule, and [The cairn-method](/cairn/concepts/the-cairn-method) for the working discipline a real plan runs inside. ### Concepts #### What cairn is cairn is continuity for agents. It is a durable task graph an agent plans into and resumes from: tasks joined by typed edges, freeform observations attached as the work happens, and handoffs that carry a session forward. An agent is sharp for the length of one session, and then the context resets. The plan, the half-finished step, the reason the last decision went the way it did — all of it lived in the conversation, so when the conversation ends, it is gone. cairn keeps that plan outside the conversation. After a reset, the agent reloads it from the graph instead of reconstructing it from a transcript. ## What "continuity" means here Continuity is the property that work survives the boundary between sessions. Without it, an agent that loses its context redoes finished steps, or stalls to ask you where things stand. cairn gives the agent somewhere durable to put the plan, and a way to read it back. The plan is a graph the agent queries, not a markdown file it re-reads top to bottom. A single `task_bootstrap` call returns the current task's state, its neighbors, its open handoffs, and recent observations. From there the agent asks the graph real questions — which phases are ready to start, what depends on a given task, what has gone stale — rather than scrolling old chat. The plan lives where a context reset cannot reach it, and the graph answers in the same terms the agent used to write it. Three nouns carry the continuity. A **task** is a node, addressed by a date-prefixed slug like `2026-06-20-ship-the-thing`, holding status, description, edges, and metadata. An **observation** is the cheapest write — a freeform note appended to a task as the work happens, the heartbeat of a session. A **handoff** is a resumable summary written for whoever picks up next, its summary load-bearing. See [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) for how each one behaves. ## Why a cairn A cairn is a pile of stones raised to mark a route across ground that holds no path of its own — a ridge, a snowfield, a stretch of moor where one direction looks like the next. A walker adds a stone and goes on. The pile stays, and shows whoever comes after which way the route ran. The word is Scottish Gaelic, *càrn*, a heap of stones. The tool keeps that habit. Each session leaves a little more on the graph and moves on, and the next one reads the stones instead of guessing the way. The stone outlasts the walker who set it, and the graph outlasts the session that wrote it. ## A continuity subsystem, shipped as an extension cairn began as the continuity subsystem inside kli, the agent runtime it was built for, and it ships as exactly the kind of extension kli exists to host. kli is a host for agent extensions; cairn is the one that handles memory across sessions. Run inside kli, cairn is at its fullest. The agent gets the tools, and the host adds three things on top: slash commands for steering by hand, per-turn folding of the current task's live state into the model's view, and folding of your observations and handoffs into the summary when a long conversation is compacted. That is why kli is the fuller home for cairn. See [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities) for how the host enforces the boundary between what an agent can read and what it can change. ## Works with any MCP client cairn does not require you to adopt kli as your main agent. Pointed at a serving process, an MCP client — Claude Code, Claude Desktop, Cursor — sees cairn's fourteen tools as MCP tools, with its workflow prompts and the `cairn-method` skill exposed as MCP prompts and readable resources. Capability gating carries over the wire, so the read / write / observe split still holds. What does not travel is the part kli provides as the host: the slash commands, context injection, and compaction folding named above stay behind. A plain MCP client still gets a complete, useful surface. One command — `kli mcp-serve cairn` — serves the extension to anything that speaks the Model Context Protocol. The command, its client configuration, and the exposed surface are documented in [mcp-serve](/cairn/cli/mcp-serve). ## The recursive loop These docs are written to be read by a machine mid-task as readily as by a person at a desk. So the pages lead with what a thing *is*, keep their anchors stable, and quote tool names and return strings verbatim — an agent cannot paraphrase a wrong string into a working call. These pages were themselves produced by an agent dogfooding the [cairn-method](/cairn/concepts/the-cairn-method), the same bootstrap, observe, and handoff loop that keeps any plan across resets. ## Where cairn earns its weight, and where it does not cairn pays off when work spans more sessions than a single context holds, when a plan has phases and dependencies worth tracking, or when more than one session might touch the same work and needs to see the others. It is a poor fit for a one-shot task you will finish in the session you start it, or a single-agent flow short enough to keep entirely in context. For those, the graph is overhead — a plain note to yourself wins. cairn tracks what you are doing without prescribing how you do it, which means the judgment of when to reach for it stays yours. ## Related - [The task graph](/cairn/concepts/the-task-graph) — the two stores and the typed-edge model underneath everything here - [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) — the three nouns and how each write behaves - [The cairn-method](/cairn/concepts/the-cairn-method) — the working loop and the disciplines that wrap it - [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) — why a write survives a rebuild - [Install cairn](/cairn/get-started/install-cairn) — bake it into kli or serve it to another client - [Your first cairn session](/cairn/get-started/your-first-cairn-session) — the bootstrap, observe, handoff loop made concrete #### The task graph A new agent session knows nothing about the work it inherited. It has a fresh context window, a goal, and no memory of the decisions that led here. The task graph is what it reads instead of remembering: a durable, queryable structure that holds the shape of the work — which task is which, which phase blocks which, what was tried and learned — outside any one conversation. cairn keeps that structure in two stores, and they exist because the work poses two different questions. ## Two stores, two questions cairn answers two different questions, so it keeps two different stores. **The task store** answers *what is the structure of the work?* It holds tasks as nodes and the typed relationships between them as edges. A task is a node addressed by a slug, a date-prefixed identifier minted at creation like `2026-06-22-add-fts-index`, carrying a status, a description, edges to other tasks, and open metadata. Edges are typed: a phase belongs to a plan, one task depends on another, two tasks are related. This is a directed graph, and the query language ([TQ](/cairn/reference/tq-language)) walks it. **The observation store** answers *what happened, and what was learned?* It holds observations, the freeform notes an agent appends as it works, indexed for full-text search. Where the task store is structured and typed, the observation store is prose. An observation is the cheapest write cairn offers, and over a session there are many of them. You do not navigate them by edge; you [search](/cairn/reference/tools#task-search) them by content. The split is the point. Structure is small, slow-changing, and worth typing strictly. Notes are voluminous, fast-flowing, and worth indexing loosely. Forcing both into one shape would make the structure noisy or the notes rigid. Keeping them apart lets each store do one job well. An agent can ask a structural question (`what is ready to work on?`) without wading through prose, or a textual question (`where did we hit the FTS5 tokenizer issue?`) without walking edges. ## Tasks are slug-addressed nodes Every task has a slug, and the slug is its address everywhere: in edges, in queries, in handoffs, in the current-task pointer. Slugs are date-prefixed, so `2026-06-22-` orders the day's work and the suffix names it. Because the address is a stable string rather than a numeric id, an agent can refer to a task by name across sessions without holding a handle, and a handoff written today still points at the right node tomorrow. A task carries a small fixed set of fields the graph queries against, plus open metadata read as text. The fixed fields a [TQ](/cairn/reference/tq-language) query can name are these eleven — six textual, two numeric, and three timestamps (universal-time, numeric-comparable and date-queryable): | Type | Fields | | --- | --- | | text | `slug`, `status`, `description`, `depot`, `parent`, `display-name` | | number | `obs-count`, `edge-count` | | timestamp | `created-ts`, `updated-ts`, `status-ts` | Anything else you set with [`task_set_metadata`](/cairn/reference/tools#task-set-metadata) is open metadata. It rides along on the node and reads as text, but it is not a typed field the query language type-checks. Status is a closed enum (`open`, `active`, `completed`, `abandoned`, `blocked`) split by the query layer into an active half (`open`, `active`, `blocked`) and a dormant half (`completed`, `abandoned`), which is how a source like `(active)` knows what to return. ## Edges come in two classes The graph has two kinds of edge, and the difference is not cosmetic: they are stored in different places and traversed differently. **Structural edges carry the fibration.** A `phase-of` edge says one task is a phase of another, and it is folded into the child's parent pointer rather than stored as a separate edge row. [`task_fork`](/cairn/reference/tools#task-fork) writes exactly such a `phase-of` edge, its default `edge_type`, when it splits a child off a parent. The store recognizes a second structural type, `forked-from`, as an internal alias of that same parent pointer. Once folded, `phase-of` and `forked-from` are indistinguishable, which is why a task has exactly one structural parent. `forked-from` is not a value any tool accepts as `edge_type`; it exists only as a classification the graph reports. The backbone these types carry is the parent pointer, the plan-to-phase tree, while the order among sibling phases is tracked on a separate link. So "one structural parent" is a claim about ownership, not ordering. **Lateral edges are relations the backbone does not carry.** `depends-on` and `related` live in a separate edge table, each a real row from a source task to a destination task. `depends-on` orders work: a phase that depends on another is not ready until the other settles. `related` is the catch-all link between two tasks that belong together without one owning the other. A task can have many lateral edges in either direction, and they are the cross-links over the structural tree. The store does not enforce acyclicity on them: a row from src to dst is rejected only when the two are the same task, so two tasks may each depend on the other. Traversal is built to tolerate that, which is why the `(:closure)` step is cycle-safe. The closed edge vocabulary a tool accepts is exactly `phase-of`, `depends-on`, `related`. That is the set [`task_link`](/cairn/reference/tools#task-link) and [`task_fork`](/cairn/reference/tools#task-fork) validate against, and an `edge_type` outside it is refused. The graph self-describes this split: the `(edges)` source returns each edge type tagged with its class, `structural` or `lateral`, so an agent can read the taxonomy off the graph rather than memorizing it. ```lisp (-> (edges) (:select :class)) ``` ```text 4 tasks: - depends-on class=lateral - forked-from class=structural - phase-of class=structural - related class=lateral ``` A query that traverses an edge validates the edge type before it touches any data; an unknown edge type is a structured error, not a silent empty result. That distinction matters to a model. An empty result means *nothing matched*; an error means *you asked the wrong question*. cairn never lets one look like the other. ## Observations index the prose Observations are stored in their own table and mirrored into a full-text index built on SQLite's FTS5. The tokenizer keeps identifiers and `file:line` spans whole, treating underscores, colons, hyphens, and dots as token characters rather than separators, so a search for `obs_fts` or `store.lisp:89` finds the note that mentions it. There is no stemming and there are no stopwords; what you wrote is what is indexed. This is why the observation store is searched, not walked. An agent re-entering a task does not traverse to its observations; it queries them by content with [`task_search`](/cairn/reference/tools#task-search), which compiles its input into an FTS5 `MATCH`. The structured graph and the searched index meet at the task: each observation belongs to a task node, so `obs-count` is a queryable field on the node, and an agent can move from *which task is busiest* (a graph query) to *what was said on it* (a search) in two calls. ## How the two stores stay one truth Both stores are projections of a single append-only event log, not independent databases that could disagree. Every write, whether a task created, an edge linked, or an observation recorded, appends one event, and the task store and the observation store are folds of that same log. The graph you query is materialized state; the log is the truth it is derived from, and the projection can be rebuilt from the log at any time. That is the subject of [events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile), and it is why the two-store split never becomes a consistency problem: there is one source, and two views of it. ## Related - [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) — the three nouns the graph is built from - [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) — how `phase-of` and `depends-on` edges make a plan you can query - [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields) — the closed enums and the eleven queryable fields, in full - [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) — why both stores are folds of one durable log - [The TQ language](/cairn/reference/tq-language) — how a query walks the typed-edge graph #### Tasks, observations, and handoffs A cairn graph is built from three nouns. A **task** is a node: a date-prefixed slug with a status, a description, edges to other tasks, open metadata, and a stream of observations. An **observation** is the cheapest write you can make, one line of freeform text appended to a task. A **handoff** is a resumable summary: a scaffolded document whose one-line summary is the first thing the next session reads. These three are all you write. Everything else cairn shows you, from the frontier to the timeline, is computed from them. Each noun maps to a verb you call and an event the call records. That is the shape of the whole system: a small, closed vocabulary of writes, each one durable, each one replayable. Understanding what each noun is, and what it is *not*, tells you when to reach for which. ## The task: a node with a date-prefixed slug A task is identified by a slug minted from the name you give it. `task_create` over the name `wire up auth` yields a slug of the form `-wire-up-auth` (the UTC date stamp followed by the slugified name) and returns `Created .`. The date namespace is system-owned. If you hand `task_create` a name that already carries a leading `YYYY-MM-DD-` prefix, it strips that before stamping today's, so the prefix never doubles and minting the same name twice in a day is idempotent. A name that recovers nothing descriptive is rejected before any event is recorded. The slug is the address. Every other write names a task by slug or operates on the current one. A task carries: - **a status** — one of `open`, `active`, `completed`, `abandoned`, `blocked`, moved with `task_update_status` (` is now .`); - **a description** — the freeform line set at creation; - **edges** — typed links to other tasks (`phase-of`, `depends-on`, `related`), added with `task_link` and removed with `task_sever`; - **metadata** — open key/value pairs set with `task_set_metadata` (`Set on .`); - **observations** — the append-only text stream described below. `task_get` renders all of this for one slug: status, description, parent, children, edges, metadata, and the five most recent observations. Past five, it appends a one-line pointer to the rest — read a task's full observation history with `timeline(full=true, types=observation)`. None of it is a file you edit. The task is a projection of the events recorded against its slug, so the rendered node is always whatever the log replays to. See [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile). Creating the first task adopts it as current when no current task is set; after that, `task_create` leaves the pointer alone. Forking with `task_fork` always switches the pointer to the child. The difference matters when you are building a plan, and it is covered in [The current task pointer](/cairn/concepts/the-current-task-pointer). ## The observation: the cheapest write, the heartbeat An observation is one line of freeform text appended to a task. You call `observe` with `text`, it records an `observation` event, and it returns `Observed on .`. That is the entire transaction. `observe` changes nothing but the observation stream and leaves your focus where it was. This cheapness is the point. Because an observation costs nothing, the design assumes you record one whenever you learn something: a dead end you ruled out, the file and line where the real bug lives, a decision and the reason behind it. They are the running record of why the task moved the way it did. The store indexes them for full-text search, so an observation written today is what a future session retrieves with `task_search` when it asks "did anyone already look at this?" See [The cairn-method](/cairn/concepts/the-cairn-method) for the discipline and [`task_search`](/cairn/reference/tools#task-search) for the retrieval side. An observation resolves to the current task unless you pass `task_id` to scope it elsewhere. Because it does not move the pointer, you can annotate a neighboring task mid-flow and stay on your own. ## The handoff: a resumable summary, summary first A handoff is a document that lets a fresh session pick up where you stopped. You call `handoff` with a one-line `summary`; it mints a path under the task scratchpad, writes a skeleton document, records a `handoff.create` event, and returns `Handoff scaffolded for at `. The skeleton is deterministic: frontmatter, a `State` snapshot computed from the task, and empty `Recent work` and `Next steps` sections. The tool never drives an authoring turn. It hands you a valid file and a path; you read the scaffold and overwrite it with the rich body. The `summary` is load-bearing: a resuming session reads it first and can often act on it without opening the file, so its precision matters more than the body's. It is also what `task_bootstrap` surfaces in its `handoffs:` block when you orient on the task. Two surfaces write handoffs, and they are not the same: - **The `handoff` tool** scaffolds the skeleton and records the event. It is deterministic and never authors prose. - **The `/handoff` command**, under a kli host, is an authoring turn: it composes the one-line summary, calls the tool to scaffold and record, reads the scaffold, then overwrites it with a thorough document — task status, critical references, recent changes in `file:line` form, learnings, artifacts, graph state, and numbered next steps. The tool gives you the durable record and the file; the command gives you the writing. Either way the summary comes first. See [Slash commands](/cairn/cli/slash-commands) for the `/handoff` authoring command and [the `handoff` tool](/cairn/reference/tools#handoff) for the scaffold it builds on. ## Three nouns, three events Every write you make against the graph reduces to one of these nouns appending one event: | Noun | Verb | Event recorded | Returns | | --- | --- | --- | --- | | Task | `task_create` | `task.create` | `Created .` | | Task | `task_fork` | `task.create` + `task.fork` | `Forked from ().` | | Task | `task_link` | `task.link` | `Linked -> ().` | | Task | `task_sever` | `task.sever` | `Severed -> ().` | | Task | `task_set_metadata` | `task.set-metadata` | `Set on .` | | Task | `task_update_status` | `task.update-status` | ` is now .` | | Observation | `observe` | `observation` | `Observed on .` | | Handoff | `handoff` | `handoff.create` | `Handoff scaffolded for at ` | Reads — `task_get`, `timeline`, `task_search`, `task_query`, and `task_bootstrap` — record no event. They project the log; they do not extend it. `task_bootstrap` in particular looks like a write because it can move the current pointer, but it records nothing: it switches focus and reads back state. The boundary between what extends the log and what merely reads it is enforced by capability, not by which tools are visible. See [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities). ## Why this shape The three nouns are deliberately uneven in cost. A task is a commitment: it has a slug, a status, a place in the graph. An observation is nearly free, one line with no ceremony, because the friction of recording what you learned is the thing that loses knowledge between sessions. A handoff sits in between: more than a note, less than a meeting, a single document whose summary a tired or truncated reader can act on without opening it. Keeping the write vocabulary this small is what makes a cairn graph durable and replayable. There is no rich task object to migrate, no handoff schema to version. There is a log of `task.create`, `observation`, `handoff.create`, and a handful of edge and status events, and the task you see is what that log folds to. ## Related - [The task graph](/cairn/concepts/the-task-graph) — how tasks and their typed edges form the durable DAG these nouns live in - [The current task pointer](/cairn/concepts/the-current-task-pointer) — which task an unscoped `observe` or `handoff` resolves to, and what moves the pointer - [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) — how each recorded event folds into the task you read back - [The cairn method](/cairn/concepts/the-cairn-method) — the bootstrap → observe → handoff loop these nouns were built for - [Slash commands](/cairn/cli/slash-commands) — the `/handoff` authoring command and the `handoff` tool it scaffolds with #### Plans, phases, and the frontier A plan in cairn is a graph of tasks, not a document. There is no `plan.md` that an agent reads, edits, and re-reads until it drifts from the work. The plan is the root task, the phases are its children, and the ordering between phases is an edge type the query language understands. Because the plan is structure rather than prose, cairn can answer a question a markdown file never could: of everything left to do, which tasks are ready *right now*. That answer is the frontier, and it is the single question an agent asks at the top of every working session. This matters because the reader resuming a plan is usually a fresh context. A flat checklist forces that fresh context to re-derive what blocks what; a graph carries the dependencies as data, so the next task is computed, not remembered. ## A plan is a root task with phase children The plan is a [task](/cairn/concepts/tasks-observations-handoffs#the-task-a-node-with-a-date-prefixed-slug) — a node in the [task graph](/cairn/concepts/the-task-graph) — that you grow phases beneath. You create the root with `task_create`, then break it into phases with `task_fork`. Each fork records a `phase-of` edge from the child up to the parent, so the plan's shape lives in the graph the moment you build it. `task_create` takes one required argument, `name`, and mints the slug from it; the slug carries today's date as a prefix. ```text task_create(name="Migrate the auth service to the new token format") -> Created 2026-06-22-migrate-auth-service. ``` `task_fork` carves a phase off whatever task it forks `from` (the parent slug; it defaults to the current task), and its `edge_type` defaults to `phase-of`. The parent argument is `from` — there is no `parent` or `parent_id`. ```text task_fork(name="Add the new token codec", from="2026-06-22-migrate-auth-service") -> Forked 2026-06-22-add-the-new-token-codec from 2026-06-22-migrate-auth-service (phase-of). ``` A phase is a phase because of its `phase-of` edge, not because of anything in its description. `phase-of` is a *structural* edge: in the store it is folded into the parent foreign key, so every task has at most one parent and the phase backbone is always a tree. That single-parent rule is what lets the plan view start from one task and walk straight down to its phases. See [The task graph](/cairn/concepts/the-task-graph#edges-come-in-two-classes) for why structural and lateral edges are stored differently. A phase is meant to be an independently verifiable unit of work — something that can move from `open` to `completed` on its own, with its own acceptance, while the rest of the plan stays put. Phases that bundle three unrelated outcomes are hard to mark done and harder to resume into. Forking one phase per checkable result keeps the frontier honest. ## depends-on edges order the phases Phase-of says *what belongs to the plan*. It says nothing about *what comes first*. Ordering is a separate, lateral edge: `depends-on`, added with `task_link`. ```text task_link(target_id="2026-06-22-add-the-new-token-codec", edge_type="depends-on", task_id="2026-06-22-cut-over-the-verifier") -> Linked 2026-06-22-cut-over-the-verifier -> 2026-06-22-add-the-new-token-codec (depends-on). ``` Read `A depends-on B` as "A cannot proceed until B is settled." A `depends-on` edge is lateral — it lives in the edges table, not the parent key — so a phase can depend on any number of others without disturbing the phase-of tree. The two edge types compose: phase-of builds the plan's spine, depends-on threads ordering across it. The closed edge vocabulary accepted by `task_link` is `phase-of`, `depends-on`, and `related`; see [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields#edge-types). Keeping the backbone and the ordering on different edge types is deliberate. You can restructure the plan (re-parent a phase) without touching the dependency order, and you can re-order work without re-parenting anything. Neither operation forces a rewrite of a prose plan, because there is no prose plan to rewrite. ## The frontier is the ready subset Two named [views](/cairn/reference/views) answer the two questions a plan poses. `plan` returns the whole plan scoped to where you are standing. `plan-frontier` returns the subset of that plan that is ready to work. The `plan` view is current-scoped: it reads from the [current task pointer](/cairn/concepts/the-current-task-pointer) and walks one hop down the phase-of backbone to the phases, falling back to your sibling phases when the current task is itself a phase. ```lisp (-> (current) (:follow :phase-of) (:or-else (-> (current) (:back :phase-of) (:follow :phase-of))) (:enrich)) ``` `plan-frontier` filters `plan` down to the tasks that are both unfinished and unblocked: ```lisp (-> (query "plan") (:where (and (not (or (= :status "completed") (= :status "abandoned"))) (all (:follow :depends-on) (or (= :status "completed") (= :status "abandoned")))))) ``` Read that predicate as the frontier rule, in two clauses: - **Not yet settled.** The task's own status is not `completed` or `abandoned`. A settled task is behind you, not ahead. (`completed` and `abandoned` are the *dormant* half of the status enum; `open`, `active`, and `blocked` are the *active* half — see [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields#statuses).) - **Every dependency settled.** Walking each `depends-on` edge forward, *all* the tasks it points to are `completed` or `abandoned`. A phase with one unfinished dependency is not on the frontier yet — its turn comes when that dependency settles. A task with no `depends-on` edges trivially satisfies the second clause: `all` over an empty set is true. So the frontier of a fresh plan is exactly its unblocked entry points. As you complete phases, the frontier advances to the phases they unblocked. You never compute this by hand; you ask for it. `frontier` is an alias — the view `frontier` is defined as `(query "plan-frontier")`, so either name returns the same ready subset. ## The shape of working a plan With the plan in the graph, an agent's session has a fixed shape, and the shape is the point. The loop is a frontier query, a completion, and a re-query: ask which tasks are ready, settle one of them, and ask again. Nothing in between edits a list. The loop runs over [task_query](/cairn/reference/tools#task-query). The frontier query inherits `plan`'s enrichment, so each line carries its inline counts — `obs=N edges=N` — after the status: ```text task_query(query="(query \"plan-frontier\")") -> 2 tasks: - 2026-06-22-add-the-new-token-codec (open) obs=0 edges=1 - 2026-06-22-write-the-migration-guide (open) obs=2 edges=0 ``` A completion is a single `task_update_status` against any task on the frontier: ```text task_update_status(status="completed", task_id="2026-06-22-add-the-new-token-codec") -> 2026-06-22-add-the-new-token-codec is now completed. ``` The re-query is the same call as before, and the phase that depended on the one just settled now appears — the frontier moved without anyone editing a list. This is the same heartbeat the [cairn-method](/cairn/concepts/the-cairn-method) wraps in research, plan, implement, and validate discipline. For the step-by-step version on a toy plan, see [Plan and resume](/cairn/get-started/plan-and-resume). ## When a plan graph is overkill A plan graph earns its keep when work outlives one context: many phases, real ordering between them, and a resume that has to recompute what is ready. For a single task you will finish in one sitting, that machinery is friction. Create the task, observe against it, and skip the phases — a one-node "plan" has a frontier of one, which is just the task itself. Reach for `task_fork` and `depends-on` when the ordering is something a future context would otherwise have to reconstruct from memory. ## Related - [Plan and resume](/cairn/get-started/plan-and-resume) — build the root, fork phases, order them with `depends-on`, and resume the plan - [The task graph](/cairn/concepts/the-task-graph) — the two stores and the structural-vs-lateral edge split - [The current task pointer](/cairn/concepts/the-current-task-pointer) — why `plan` and `plan-frontier` are current-scoped - [Views](/cairn/reference/views) — the built-in views `plan`, `plan-frontier`, `leaf-tasks`, and `stale-phases` with their TQ source text - [The TQ language](/cairn/reference/tq-language) — the query language the frontier rule is written in #### The current-task pointer The current-task pointer is a single task slug cairn holds for your session and resolves against whenever a tool omits an explicit task. You call `observe` with a line of text and no slug, and cairn knows where to file it; you call `handoff` with a summary and no slug, and it scaffolds under the right task. Nothing in those calls named a task, yet every write landed on one — the pointer supplied the target. It is the difference between an agent that has to repeat its own slug on every call and one that can plan, observe, and hand off in a flow because cairn already knows what it is working on. ## What the pointer is The current-task pointer is one task slug, stored per session, that names the task in focus right now. It is not a property of the [task graph](/cairn/concepts/the-task-graph) — the graph does not have a "current" node. The pointer lives next to the session, alongside the open database handle, and it is what makes a slugless tool call meaningful. Without a pointer, "this task" has no referent and a current-scoped write fails rather than guessing. Two sessions never share a pointer. The pointer is keyed in the active protocol's own storage, so a serving cairn over MCP keeps a separate current task for each connected client. One agent forking into a new phase does not move another agent's focus. Concurrency is real, and each session carries its own answer to "what am I on." ## How a tool finds its task The write tools that target a task — `observe`, `task_link`, `task_sever`, `task_set_metadata`, `task_update_status`, `handoff` — resolve their target the same way. The tool takes the `task_id` argument when you pass one; otherwise it falls back to the current pointer; and if neither names a task, it fails with: ```text No current task; create or select one first. ``` That fallback is the whole point. In the common case you set the pointer once and then write against it without repeating the slug. When you need to touch a different task without leaving the one you are on, you pass `task_id` — and that is the second half of the rule, the part that surprises people. ## Acting on a task does not make it current A `task_id` argument scopes one call. It does not move the pointer. You can drop an observation on a sibling task, read another task's state, or flip a blocker to `completed`, all while the pointer stays exactly where it was. ```text observe(text: "the parser handles the empty case", task_id: "2026-06-22-parser-edge-cases") → Observed on 2026-06-22-parser-edge-cases. ``` After that call, the current task is still whatever it was before. The observation went where you aimed it; your focus did not follow. This separation lets an agent record a quick note on a related task and return to its own work without an explicit switch-back step. The slug you passed is a one-shot address, not a new home. Two surfaces deliberately break this rule, because their job is to move you. ## What moves the pointer Three operations set the current task. Each is explicit about it. **Creating the first task adopts it.** `task_create` mints a date-prefixed slug and records the task. If — and only if — no task is current, it adopts the new one as current, so a fresh session that creates a task is immediately working on it. Create a second task and the pointer does not move; you are still on the first until you say otherwise. This is the gentle case: adoption fills an empty pointer, never overwrites a full one. ```text task_create(name: "rework the search backend") → Created 2026-06-22-rework-the-search-backend. ``` **Forking always switches.** `task_fork` spawns a child task and unconditionally makes the child current. Forking is how you descend into a [phase of a plan](/cairn/concepts/plans-phases-and-the-frontier), and the assumption is that you want to work in the phase you just opened. The parent comes from the `from` argument — a parent slug — and defaults to the current task when you omit it. Pass neither a `from` nor have a current task, and the fork fails: ```text No parent task; pass from or select a task first. ``` So `task_fork` reads the pointer (as the default parent) and then overwrites it (with the new child) in a single call. After a fork you are one level deeper, on the phase, with the parent recorded as the edge source. ```text task_fork(name: "migrate the index schema", from: "2026-06-22-rework-the-search-backend") → Forked 2026-06-22-migrate-the-index-schema from 2026-06-22-rework-the-search-backend (phase-of). ``` **Bootstrapping orients and may switch.** `task_bootstrap` is the set-current surface for resumption — the one call you make to re-enter work. Its pointer behavior splits on whether you name a task: - With an explicit `task_id`, it switches the pointer to that task. Orienting on a task makes it current, so any session you spawn and the [per-turn context cairn injects](/cairn/concepts/events-projection-and-reconcile) agree on which task is in focus. - With no `task_id`, it orients on the current task and leaves the pointer untouched; if no task is current, there is nothing to orient on and the call fails. Either way, `task_bootstrap` records no event and switches the pointer only after it confirms the task exists. If you name a task that is not there, the pointer does not move; you get a not-found result and a list of recent slugs to aim at instead. With nothing to orient on at all: ```text No task to bootstrap; pass task_id or select a task first. ``` | Operation | Effect on the pointer | | --- | --- | | `task_create` | Adopts the new task only when none is current | | `task_fork` | Always switches to the child | | `task_bootstrap` with `task_id` | Switches to the named task (after it is found) | | `task_bootstrap` without `task_id` | Orients on the current task; never moves the pointer (fails if none is current) | | Any tool's `task_id` argument | None — scopes one call, leaves the pointer alone | ## Why the pointer feeds every turn The pointer does more than route writes. Each model turn, cairn reads the current pointer live, renders that task's computed state and its open handoffs, and splices the block in as ephemeral context — present for that turn, never written to the durable log. The agent sees what it is working on without being told, and the block always reflects live state because it is rebuilt from the pointer and the store every turn rather than cached. This is the recursive payoff of a per-session pointer. Move it with `task_fork` and the very next turn's injected context is the new phase. Resume a task with `task_bootstrap` and the context block follows your focus. The pointer is the one piece of session state that decides both where writes land and what the model is reminded of. That is why moving it is always explicit, never a side effect of touching another task. ## When the pointer is more than you need A single-task, single-session run barely exercises the pointer: you create one task, it is adopted, and every slugless call lands on it for the life of the session. The rules above earn their keep when work fans out — multiple phases, multiple sessions, a `task_id` aimed sideways while focus stays put. For one linear thread of observations, walk through [your first session](/cairn/get-started/your-first-cairn-session) and let adoption do the work; the switch-versus-scope distinction can wait until you fork. ## Related - [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) — how `task_fork` descends into the phase the pointer then follows - [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) — the writes that resolve against the pointer - [The cairn-method](/cairn/concepts/the-cairn-method) — where bootstrap sits in the orient, record, hand off loop - [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) — the per-turn context block the pointer drives - [Tools reference](/cairn/reference/tools#task-bootstrap) — the exact signatures of `task_create`, `task_fork`, and `task_bootstrap` #### Events, projection, and reconcile cairn stores no state you cannot reconstruct. Every write is one event appended to a durable, append-only log; everything you query — the task graph, observations, handoffs, metadata, even the views you define — is a projection folded from those events. The log is the truth. The database is a cache of the log, and it can be thrown away and rebuilt. This is why a cairn store survives a crash mid-write, moves between machines as plain text, and resumes the same way every time: the answer to "what happened?" is always the events, replayed in the same order. You do not call any of this. There is no commit, no flush, no migration step in the tool surface. Understanding the model tells you why cairn's writes are cheap, why two agents writing the same store do not corrupt it, and why the durable artifact on disk is a file you can read. ## The event log is the source of truth Each task owns a log: a single `events.ndjson` file, one JSON event per line. That file is canonical. Every observation, status change, link, fork, and handoff is appended to it as an event before anything else happens, and an event, once written, is never edited or deleted. To know the full history of a task, you read its log top to bottom. The line format is deterministic. cairn encodes each event with its keys sorted and its `data` payload nested as a canonical object, so the same event always serializes to the same bytes — the encoder and decoder are exact inverses, and a line decoded on one machine folds into the same state on another. An event carries its identity and provenance: `event_id` is a portable ULID, the stable id that travels with the event; `event_key` is the de-duplication key; `task_id`, `type`, `ts`, and the session that wrote it ride along too. The local row number is the only field the line does not carry, because it is local. Because the log is append-only NDJSON, it is durable and portable for free. There is no binary format to corrupt, no schema to migrate in place. Copy the directory and you have copied the history. ## The projection is a fold over events You never query the log directly. cairn folds the events into a SQLite projection — the materialized view your reads run against — and that projection holds the shapes you actually ask about: the `tasks` table, the `edges` between them, the full-text `observations` index, `handoffs`, `task_metadata`, and `named_views`. Every one of these is derived. Apply the events in order and the projection appears; the same events always produce the same projection. Two consequences matter. First, the projection is disposable: it is a cache, and cairn can rebuild it from the logs at any time. Second, replay is **portable**, not local. cairn folds the log by `(ts, event_id)` — timestamp, then ULID as tiebreaker — not by the local row number a given database happened to assign. The local `seq` orders one database's storage; the portable order reproduces the *same* state on any store, regardless of the order rows landed locally. That distinction is what lets a store rebuilt on a fresh machine match the original exactly. User-defined views are not special. When you call `define!` to name a query (see [Built-in and user-defined views](/cairn/reference/views)), cairn records a view-definition event and folds it into the `named_views` table. `undefine!` records the inverse. Replay reproduces your view vocabulary along with everything else, because a view is just more events in the log — there is no separate place your customizations live and could be lost. ## Reconcile unions the log and the cache The two stores can drift. A database row gets committed but the matching append to the log never lands — the crash gap between a write and its mirror. Or you copy fresh logs into a store whose cache predates them. cairn closes both gaps on store-open with **reconcile**, which unions the per-task logs (the truth) with the SQLite cache (rebuildable) and brings them back into agreement. Reconcile runs in three moves: **Ingest log lines the cache lacks.** cairn replays every log into the database, skipping events it already holds — de-duplication is keyed on `event_key`, so re-ingesting the same event is a no-op. If any new event enters the cache, cairn re-folds the whole projection in portable order, so the materialized state reflects the full log rather than a partial splice. **Export cache rows the logs lack.** For the reverse gap — a committed row whose append never reached the file — cairn appends those events back to the right task's log. This is the only path that writes the log during open, and it writes only what is missing, comparing by `event_key`. After this step, every event in the cache is also on disk. **Refresh the watermarks.** cairn stamps each log's current watermark so the next open sees an unchanged store and skips it. That last move is the efficiency story. A watermark is a content hash and byte length of a log file, stored in the database's `schema_meta`. On open, cairn compares each log's current watermark against the stored one; a file that has not moved is skipped in both directions. An unchanged store reconciles to a no-op — reconcile is cheap precisely because most logs do not change between opens. A store with no backing file (an in-memory store) reconciles to a no-op as well. ## Why the order is append-then-project The durable append happens first, and the cache update rides only after it. cairn appends the event to the log, then folds it into the projection — never the other way around. During ingest, cairn deliberately suppresses the mirror-write step, because the events it is replaying are already on disk; mirroring them again would be redundant work against the file it is reading from. This ordering is what makes the crash gap recoverable rather than fatal. If cairn crashes after the log append but before the cache catches up, the next open's ingest step replays the missing line and the projection heals. If it crashes after a cache write but before the mirror append — the rarer gap — the export step writes the line back. Whichever side is ahead, reconcile pulls the other forward, and because both directions key on `event_key`, neither can double-apply. ## Where this model earns its keep, and where it does not Event sourcing is not free. The projection is a second copy of state that must be kept in step, replay has a cost proportional to history, and reasoning about a fold is more work than reasoning about a row you overwrote. cairn takes that cost on purpose, because its whole job is continuity: an agent must be able to resume into the exact state a prior session left, on a machine that may not be the one that wrote it, after a context reset or a crash. A durable, portable, replayable log is the mechanism that delivers it, and the [current-task pointer](/cairn/concepts/the-current-task-pointer) is the one piece of state deliberately kept *outside* this log, because it is per-session, not part of the shared history. If your work is a single throwaway task in one session, you will never see reconcile do anything and the projection will never need rebuilding — the model is overkill, and that is fine; it stays out of your way. The model pays off the moment work outlives a session or is shared across more than one agent. That is the case cairn is built for. ## Related - [The task graph](/cairn/concepts/the-task-graph) — the typed-edge graph and observation index that the projection materializes - [Tasks, observations, handoffs](/cairn/concepts/tasks-observations-handoffs) — the three nouns that become events - [The current-task pointer](/cairn/concepts/the-current-task-pointer) — the per-session state kept outside the event log - [Views](/cairn/reference/views) — how `define!` records a view event that replay reproduces #### Reads, writes, and capabilities A capability is a tag on a tool that says what kind of effect the tool can have on the store. cairn stamps every tool with one of three: `:cairn/read`, `:cairn/write`, or `:cairn/observe`. The boundary between reading and writing is drawn by those tags, in the tool's `:metadata`, not by which tools a client happens to expose. A host that grants a session `:cairn/read` but withholds `:cairn/write` gets a session that can query, inspect, and orient, and cannot change a single edge — no matter what it calls. This matters because cairn is one durable graph that many sessions touch at once, and an agent that can plan freely should not be able to overwrite another session's plan by accident. The split is coarse on purpose: three capabilities, not fourteen, so a host reasons about a session by the class it granted rather than by the tool list it exposed. ## Three capabilities, one per effect class Every tool declares its capability in its registration. The classes partition the fourteen tools cleanly. `:cairn/observe` covers exactly one tool: `observe`. An observation is the cheapest write cairn has — it appends one freeform note and moves nothing else — so it gets its own capability. A host can hand a session the right to record observations without the right to restructure the graph. `:cairn/write` covers the eight tools that change graph structure or state: `task_create`, `task_fork`, `task_link`, `task_sever`, `task_set_metadata`, `task_update_status`, `handoff`, and `task_query_write`. These create tasks, draw and cut edges, move status, set metadata, scaffold handoffs, and run the mutating query forms. Each appends an event through the same durable boundary; see [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile). `:cairn/read` covers the five tools that compute over the store and return text without touching it: `task_search`, `task_get`, `timeline`, `task_query`, and `task_bootstrap`. A read tool resolves a target, hydrates state, renders it, and returns. `task_bootstrap` switches the current-task pointer as a side effect of orienting, but it records no event and writes nothing to the graph — the [pointer is per-session](/cairn/concepts/the-current-task-pointer), not part of the durable task graph, so moving it is a read-class act. The full per-tool capability column lives in [the tools reference](/cairn/reference/tools). ## The query language carries the gate as one dynamic variable The two query tools are the interesting case, because both run the same language — TQ — over the same store, and the only difference between them is whether writes are allowed. cairn does not give the read tool a smaller language and the write tool a bigger one. It gives them the identical interpreter and flips one dynamic variable. `task_query` runs TQ with mutation refused. `task_query_write` runs TQ with mutation allowed. When the gate is closed, any form whose head ends in `!` — a mutating step like `(:set-status! "completed")` or a write source like `(define! ...)` — is refused before it can record anything. The refusal is a structured error, not a crash: ```text Query error: Mutations need the task_query_write surface; task_query is read-only. ``` One variable being the entire gate has a useful consequence: the read surface is *provably* non-mutating. You do not audit fourteen runners to trust that `task_query` cannot write; you audit one branch. Two guards back that branch. The append is gated by the flag the read tool binds shut, so a mutating step is refused before it reaches the durable boundary. And the read tool withholds the write context entirely — it binds the event sink to nothing — so even a step that somehow slipped the flag would have no boundary to append through. The check fires on a step's internal `:mutation` kind tag, the one `(-> (schema) (:select :kind))` projects as `kind=mutation`, not on registry membership, so a new mutating step is gated the moment it is tagged, with no second list to keep in sync. ## Set-operation operands stay read-only on both surfaces There is a subtler rule inside `task_query_write`. The set-algebra steps — `(:union Q)`, `(:intersect Q)`, `(:minus Q)`, `(:or-else Q)` — and the operand of `(define! "name" Q)` evaluate their sub-query `Q` with the write gate shut, even when the outer query runs on the write surface. You combine and define *over* selections; you do not mutate *inside* them. So any `!`-form inside an operand is refused — it is an error, not a silently skipped step. This is a legal write-surface query: the operand `(dormant)` is read-only, and the outer `(:set-status! "completed")` runs against everything `(active)` keeps after the minus: ```lisp (-> (active) (:minus (dormant)) (:set-status! "completed")) ``` The mutation step returns the set it touched — each node now carrying the written status — so the call renders that set, and a trailing `(:count)` would report how many tasks it moved: ```text 2 tasks: - 2026-06-20-wire-the-cache (completed) - 2026-06-21-link-check-gate (completed) ``` Now move the mutation *into* the operand. The operand is evaluated with the gate bound shut, so the `(:set-status! "open")` inside `(:minus ...)` hits the same refusal as a write on the read surface: ```lisp (-> (active) (:minus (-> (dormant) (:set-status! "open"))) (:set-status! "completed")) ``` Because the refusal is a structured `cairn-query-error` and the only handler is at the top of the runner, the *whole call* fails: it returns `isError:true`, the outer `(:set-status! "completed")` never executes, and nothing is touched. There is no per-step recovery — one refused operand mutation aborts the entire query. "Operands are read-only" means a write inside an operand aborts the call, not that the inner write is dropped while the outer write proceeds. A query that builds a target set by composing other queries can never have one of those building-block queries quietly change the data it selects from, and it can never half-apply either. ## A capability is not a promise that the tool was exposed The capability tag describes what a tool *would* do if called; it does not by itself decide whether a client can see the tool. Exposure is the host's job. A kli host reads the `:capabilities` metadata and decides, per session, which tools to register and which to deny. Each read tool, for instance, carries the same shape the host inspects: ```lisp :metadata '(:capabilities (:cairn/read)) ``` The host never executes the tool to learn its class; it reads that tag and routes on it. Over plain MCP — [`kli mcp-serve cairn`](/cairn/cli/mcp-serve) — the same metadata travels with each tool so any MCP client can reason about it the same way. This is why the boundary is enforced by capability and not by tool-name exposure. Hiding `task_query_write` from a client's tool list is a presentation choice; refusing `!`-forms at the interpreter is the enforcement. The first can be reconfigured per host; the second holds wherever TQ runs. The second is the load-bearing one: an agent holding only `:cairn/read` that sends a mutation through `task_query` meets the refusal above, because the gate lives at the interpreter rather than in whatever tool list the host chose to expose. The write surface is `task_query_write`, reachable only when the host has granted it. ## When the split is more than you need For a single agent on a one-off task, the read/write/observe distinction is invisible — you hold all three and never think about which class a tool falls in. The capability model earns its keep when more than one session shares a store, or when a host wants to grant a constrained session (a reviewer, a watcher, a search-only helper) the ability to look without the ability to touch. If that is not your situation, treat this page as background: the tools behave the same whether or not you ever think about the tag they carry. ## Related - [The tools reference](/cairn/reference/tools) — every tool with its capability gate in the parameter header - [The TQ language](/cairn/reference/tq-language) — the read-versus-write gate inside the query grammar, step by step - [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) — the durable boundary every write passes through - [The current task pointer](/cairn/concepts/the-current-task-pointer) — why moving it is a read-class act - [Serve cairn over MCP](/cairn/cli/mcp-serve) — how capability metadata travels over MCP #### The cairn-method The cairn-method is a working discipline, not a feature. cairn gives an agent a durable place to keep work; the cairn-method is how an agent uses that place so a fresh session can pick the work back up. It is a loop with four phases: research, plan, implement, validate. Two heartbeat writes hold the phases together — `observe` and `handoff` — and one orienting read, `task_bootstrap`, re-enters the loop after a reset. The method ships as a skill (`cairn-method`) and six bundled prompts, so the agent reading this page can load the same discipline that authored it. ## Why a method and not just tools A model can call [the fourteen tools](/cairn/reference/tools) without any method and get nothing durable. The graph fills with tasks that have no observations, plans whose phases never settle, handoffs that summarize nothing a next session can act on. The tools are necessary but insufficient on their own. What makes the work resumable is the order the agent writes in — what it records, when, and against which task. The cairn-method names that order. It says: orient before writing, record findings as they surface, structure the plan as a graph the frontier can read, advance only on a real gate, and leave a summary the next session reads first. Each rule maps onto a tool or a query that already exists. The method is the contract between the agent and its future self. ## The loop The method runs as a loop across sessions, not a script inside one. A session enters by orienting, does one kind of work, and leaves a trail. The next session — the same agent after a context reset, or a different one — enters the same way and continues. This section is the cross-session heartbeat — orient, record, hand off; the four work phases (next section) run inside it. **Orient.** A session begins with [`task_bootstrap`](/cairn/reference/tools#task-bootstrap). One call returns the task's state, its neighbors, any open handoffs, and the recent observations. An explicit `task_id` switches the [current-task pointer](/cairn/concepts/the-current-task-pointer) to that task; with no `task_id`, it orients on the current task and adopts it only when one is not yet set. Called with neither a `task_id` nor a current task, it returns `No task to bootstrap; pass task_id or select a task first.` `task_bootstrap` records no event, so re-entering costs nothing — the call repeats freely whenever the thread is lost. **Record.** Throughout the work, [`observe`](/cairn/reference/tools#observe) is the heartbeat — the cheapest write cairn has. It appends one freeform observation to the current task and returns `Observed on .` It does not move the pointer and it does not need a status change to justify it. The cairn-method favors liberal observation over a mandated cadence: a research finding, a red-then-green test, a constraint hit, a decision made. These observations are what [`task_search`](/cairn/reference/tools#task-search) later retrieves, so a finding written once need never be re-derived. **Hand off.** When the work spans sessions, [`handoff`](/cairn/reference/tools#handoff) scaffolds a resumable note. The one-line summary comes first and is the load-bearing field a resuming session reads before anything else; the tool returns `Handoff scaffolded for at ` and writes a skeleton, which a rich body then overwrites. The summary is the contract; the file supplements the live timeline and observations, and never contradicts them. When the two disagree, the live stream wins. This loop is recursive: the page you are reading was produced by an agent running it — see [What cairn is](/cairn/concepts/what-cairn-is) for that story in full. ## The four phases Inside the loop, the work itself moves through four phases. Each ships as a prompt — `research`, `plan`, `implement`, `validate` — and each leans on the `cairn-method` skill for the discipline rather than restating it. Two more prompts, `handoff` and `resume`, wrap the loop's heartbeat and re-entry. Six prompts in total. **Research documents what is.** Before anything changes, research documents the codebase as it stands — findings, not prescriptions. Every claim carries a `file:line` or a source URL; hypotheses stay phrased as hypotheses until a root cause is reproduced. The method requires a current task before any current-scoped write, because `observe` and the `knowledge` query both resolve against it, and every finding then sinks through `observe`. It also expects a `task_search` before re-investigation, since the answer may already be in the graph. **Plan structures the work as a graph.** A plan in cairn is a task DAG, not a markdown file — [the graph is the source of truth](/cairn/concepts/plans-phases-and-the-frontier). Phases are child tasks created with [`task_fork`](/cairn/reference/tools#task-fork) (default edge `phase-of`); real prerequisite ordering is a `depends-on` edge added with [`task_link`](/cairn/reference/tools#task-link). Because `task_fork` makes the new child current unconditionally, the method always names the parent with `from=`. Each phase carries its own acceptance and is independently verifiable. There is no scaffold tool; the edges are built with the tools above. **Implement advances on real gates.** Work moves through the DAG one ready phase at a time, asking `(query "plan-frontier")` for the next phase whose every `depends-on` predecessor is settled. A phase can be advanced without moving the pointer: passing `task_id=` to `observe` and [`task_update_status`](/cairn/reference/tools#task-update-status) targets it while the plan stays current. The gate is irreducible — no phase reaches `completed` until its build, tests, and typecheck actually pass. The passing output advances the DAG, not vibes. **Validate is independent and skeptical.** A separate pass runs the gates itself rather than trusting a self-reported result, reviews the code against the DAG, and reports honestly. It reads `(query "plan")` for completion status, `(query "stale-phases")` for phases active under a finished parent, and `timeline` to reconstruct what was actually done. It classifies findings as matches, deviations, or potential issues, each with a `file:line`, and refuses to green-light advancement while build or test failures remain. These phases are documentarian and test-first by design. The method documents what is, not what should be; it writes the failing test first, for the right reason; it leaves zero `TODO`/`FIXME`/`HACK` in shipped code. It treats honest deviation as content — when reality differs from the plan, the agent pauses, surfaces the gap, gets a decision, and records it with `observe`. ## Where the method is overkill The cairn-method earns its weight when work outlives a single context window — multi-session efforts, plans with real phase ordering, or several agents touching the same tree. It is overkill for a one-off task finished in one sitting and never resumed. For a single quick change, a `task_create` and one `observe` is the whole method; forking phases and writing a handoff for work that ends in the same turn is ceremony, not continuity. The full loop is for the case where a reset would otherwise cost the context — that is what cairn exists for. When it would not, a plainer note wins. ## Related - [What cairn is](/cairn/concepts/what-cairn-is) — continuity for agents, and the recursive loop in depth - [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) — why a plan is a DAG and how the frontier picks the ready work - [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) — the three nouns the loop writes - [The current-task pointer](/cairn/concepts/the-current-task-pointer) — what `task_bootstrap` and `task_fork` move, and what `task_id` leaves alone - [Serve cairn over MCP](/cairn/cli/mcp-serve) — exposing the tools, the six prompts, and the cairn-method skill over MCP ## Reference ### Tools & Query Language #### MCP tools cairn provides fourteen MCP tools. They are the wire surface an agent calls to plan in the task graph and resume into it: one observe tool, eight writes, and five reads. Each entry below mirrors the tool's registered schema, so the page tracks the served surface and does not drift from it. Every tool carries a capability gate in its metadata: `:cairn/observe`, `:cairn/write`, or `:cairn/read`. The gate is the boundary, not the tool name — read and write are decided by capability, not by which tools are exposed (see [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities)). A call whose gate is not granted runs nothing. Errors travel on two channels. Protocol faults — malformed JSON-RPC, an unknown tool, a missing required argument the schema rejects — surface as transport-level errors. Tool-execution failures surface as a normal tool result with `isError: true`, carrying the verbatim text the model reads and self-corrects from: `name is required.`, `No current task; create or select one first.`, and the rest quoted in the entries below. Every returned and error string on this page is copied from source; treat the punctuation as load-bearing. Most tools resolve a target task the same way: the `task_id` argument when given, otherwise the [current task pointer](/cairn/concepts/the-current-task-pointer). When neither names a task, a current-scoped call fails with `No current task; create or select one first.` Task arguments accept a bare slug or a depot-prefixed one; the depot prefix is stripped before lookup. ## Tools by capability | Tool | Capability | One line | | --- | --- | --- | | [`observe`](#observe) | `:cairn/observe` | Record a freeform observation on the current task. | | [`task_create`](#task-create) | `:cairn/write` | Create a top-level task. | | [`task_fork`](#task-fork) | `:cairn/write` | Create a child task and make it current. | | [`task_link`](#task-link) | `:cairn/write` | Add a typed edge to a target task. | | [`task_sever`](#task-sever) | `:cairn/write` | Remove a typed edge to a target task. | | [`task_set_metadata`](#task-set-metadata) | `:cairn/write` | Set a free key/value on a task. | | [`task_update_status`](#task-update-status) | `:cairn/write` | Set a task's status. | | [`handoff`](#handoff) | `:cairn/write` | Scaffold a resumable handoff. | | [`task_query_write`](#task-query-write) | `:cairn/write` | Run a TQ query that may mutate or define views. | | [`task_search`](#task-search) | `:cairn/read` | Full-text search over observations. | | [`task_get`](#task-get) | `:cairn/read` | Read computed task state. | | [`timeline`](#timeline) | `:cairn/read` | Recent events for a task. | | [`task_query`](#task-query) | `:cairn/read` | Run a read-only TQ query. | | [`task_bootstrap`](#task-bootstrap) | `:cairn/read` | Orient on a task in one call. | ## Tool reference The entries below run observe, then the write tools, then the reads. Each one uses the same field order: summary, capability gate, signature, parameters, returns, semantics, example, see also. ### observe Record a freeform observation on the current task. This is the cheapest call and the work heartbeat. Capability gate: `:cairn/observe`. Signature: `observe(text, task_id?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `text` | string | yes | The observation. Blank text fails with `text is required.` | | `task_id` | string | no | Task to observe on. Defaults to the current task. | Returns: `Observed on .` Records one `observation` event. Semantics: an observation never moves the current pointer; it appends to the task you are already on (or to `task_id`). Observations are the corpus [`task_search`](#task-search) ranks over. Example: `observe(text="bm25 ranking confirmed against t/cairn-search.lisp")` on task `2026-06-22-tune-search` → `Observed on 2026-06-22-tune-search.` See also: [The cairn-method](/cairn/concepts/the-cairn-method) — [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs). ### task_create Create a top-level task. Adopts the new task as current only when no current task is set. Capability gate: `:cairn/write`. Signature: `task_create(name, description?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Descriptive task name. Blank fails with `name is required.` Slugified and stamped with today's UTC date; a name too thin to slugify is rejected. | | `description` | string | no | Longer description stored on the task. | Returns: `Created .` Records one `task.create` event. Semantics: the slug is `YYYY-MM-DD-`. Minting is idempotent over the date prefix — a name you already date-prefixed is stripped back to its core before today's prefix is stamped, so the prefix stays singular. The task becomes current only if none was set; an existing pointer is left undisturbed. Example: `task_create(name="tune search ranking")` with no current task → `Created 2026-06-22-tune-search-ranking.` See also: [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) — [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs). ### task_fork Create a child task linked to a parent and make the child current. The default edge is `phase-of`, so forking builds a plan's phase backbone. Capability gate: `:cairn/write`. Signature: `task_fork(name, from?, edge_type?, description?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Child task name. Blank fails with `name is required.` | | `from` | string | no | Parent slug. Defaults to the current task; with neither, fails with `No parent task; pass from or select a task first.` The parent argument is `from`. | | `edge_type` | string | no | One of `phase-of`, `depends-on`, `related`. Defaults to `phase-of`. An out-of-enum value fails with `"" is not a valid edge type; expected one of phase-of, depends-on, related.` | | `description` | string | no | Longer description for the child. | Returns: `Forked from ().` Records `task.create` for the child and `task.fork` on the parent. Semantics: `task_fork` always switches the current pointer to the new child. A task cannot fork from itself; that fails with `A task cannot fork from itself.` Example: `task_fork(name="add prefix query support")` while current on `2026-06-22-tune-search` → `Forked 2026-06-22-add-prefix-query-support from 2026-06-22-tune-search (phase-of).` See also: [Plan and resume](/cairn/get-started/plan-and-resume) — [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) — [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields). ### task_link Create a typed edge from the current task to a target task. Capability gate: `:cairn/write`. Signature: `task_link(target_id, edge_type, task_id?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `target_id` | string | yes | Edge destination slug. Blank fails with `target_id is required.` | | `edge_type` | string | yes | One of `phase-of`, `depends-on`, `related`. An out-of-enum value fails with `"" is not a valid edge type; expected one of phase-of, depends-on, related.` | | `task_id` | string | no | Edge source. Defaults to the current task. | Returns: `Linked -> ().` Records one `task.link` event. Semantics: links the resolved source to `target_id`. A task cannot link to itself; that fails with `A task cannot link to itself.` Use `depends-on` for lateral ordering inside a plan and `related` for cross-references; `phase-of` names structural parentage. See [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields) for the closed enum. Example: `task_link(target_id="2026-06-22-tokenizer-fix", edge_type="depends-on")` on `2026-06-22-add-prefix-query-support` → `Linked 2026-06-22-add-prefix-query-support -> 2026-06-22-tokenizer-fix (depends-on).` See also: [`task_sever`](#task-sever) — [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields) — [The task graph](/cairn/concepts/the-task-graph). ### task_sever Remove a typed edge from the current task to a target task. Capability gate: `:cairn/write`. Signature: `task_sever(target_id, edge_type, task_id?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `target_id` | string | yes | Edge destination slug. Blank fails with `target_id is required.` | | `edge_type` | string | yes | One of `phase-of`, `depends-on`, `related`. An out-of-enum value fails with `"" is not a valid edge type; expected one of phase-of, depends-on, related.` | | `task_id` | string | no | Edge source. Defaults to the current task. | Returns: `Severed -> ().` Records one `task.sever` event. Semantics: the inverse of [`task_link`](#task-link) — it removes the named edge. Severing is recorded as its own event, so the graph's history shows when an edge was dropped. Example: `task_sever(target_id="2026-06-22-tokenizer-fix", edge_type="depends-on")` on `2026-06-22-add-prefix-query-support` → `Severed 2026-06-22-add-prefix-query-support -> 2026-06-22-tokenizer-fix (depends-on).` See also: [`task_link`](#task-link) — [The task graph](/cairn/concepts/the-task-graph). ### task_set_metadata Set a free key/value on a task — for example `display-name`, `phase`, `objective`, `acceptance`, or `tags`. Capability gate: `:cairn/write`. Signature: `task_set_metadata(key, value, task_id?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | yes | Metadata key. Blank fails with `key is required.` | | `value` | string | yes | Value to store. Required by the schema; a null value is coerced to the empty string rather than rejected. | | `task_id` | string | no | Task to annotate. Defaults to the current task. | Returns: `Set on .` Records one `task.set-metadata` event. Semantics: metadata is open — any key is accepted, and setting an existing key overwrites it. Most keys are read back as open text. A handful map to typed [queryable fields](/cairn/reference/edges-statuses-and-fields) (for instance `display-name`); the rest stay free-form. Example: `task_set_metadata(key="acceptance", value="prefix queries return ranked snippets")` on `2026-06-22-add-prefix-query-support` → `Set acceptance on 2026-06-22-add-prefix-query-support.` See also: [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields) — [Tasks, observations, handoffs](/cairn/concepts/tasks-observations-handoffs). ### task_update_status Set a task's status. Idempotent. Capability gate: `:cairn/write`. Signature: `task_update_status(status, task_id?, reopen?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `status` | string | yes | One of `open`, `active`, `completed`, `abandoned`, `blocked`. Missing fails with `status is required.`; out of enum fails with `"" is not a valid status; expected one of open, active, completed, abandoned, blocked.` | | `task_id` | string | no | Task to update. Defaults to the current task. | | `reopen` | boolean | no | When true and `status` is omitted, sets the status to `active` to revive a completed task. | Returns: ` is now .` Records one `task.update-status` event. Semantics: status is lower-cased before validation. Re-applying the same status is a no-op event-wise but still returns the line. Marking a phase `completed` is what advances the plan frontier (see [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier)). Example: `task_update_status(status="completed")` on `2026-06-22-tokenizer-fix` → `2026-06-22-tokenizer-fix is now completed.` See also: [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields) — [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier). ### handoff Scaffold a resumable handoff for the current task. The summary is the load-bearing field; an optional path points at a written note. Capability gate: `:cairn/write`. Signature: `handoff(summary, path?, task_id?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `summary` | string | yes | One-line summary of where the work stands. Blank fails with `summary is required.` | | `path` | string | no | File path for the note. Defaults to a timestamped file under the task scratchpad. | | `task_id` | string | no | Task to hand off. Defaults to the current task. | Returns: `Handoff scaffolded for at ` — no trailing period. Records one `handoff.create` event. Semantics: `handoff` is deterministic. It mints a path, writes a skeleton (frontmatter, a computed state snapshot, and empty `## Recent work` / `## Next steps` sections), records the event, and returns the path. It never drives an authoring turn — the caller overwrites the skeleton with the rich body. An existing file at the path is left in place. The richer, interactive authoring flow is the [`/handoff` slash command](/cairn/cli/slash-commands), which is separate from this tool. Example: `handoff(summary="prefix queries land; tokenizer fix is next")` on `2026-06-22-add-prefix-query-support` → `Handoff scaffolded for 2026-06-22-add-prefix-query-support at /2026-06-22_14-30-05_prefix-queries-land-tokenizer-fix-is-next.md` See also: [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) — [Slash commands](/cairn/cli/slash-commands). ### task_query_write The write surface for the TQ query language, accepting the `!`-forms that [`task_query`](#task-query) refuses. Capability gate: `:cairn/write`. Signature: `task_query_write(query)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `query` | string | yes | A single TQ s-expression. Accepts mutating steps (`:set-status!`, `:set!`, `:link!`, `:unlink!`) and the view forms `define!` / `undefine!`. | Returns: the rendered query result as text. A mutation step records one append-only event per task it touches and returns the set, so a trailing `(:count)` reports how many tasks were affected. `define!` records the named view in the durable log. Semantics: mutations converge on re-run — the same query applied twice reaches the same state. Sub-query operands inside set operations always evaluate read-only, even on this surface. `(define! "name" Q)` records `Q` as a reusable named view resolvable as `(query "name")`; a user view shadows a built-in of the same name. Read-only queries also run here, but prefer [`task_query`](#task-query) for them. Example: `task_query_write(query="(-> (query \"plan-frontier\") (:set-status! \"active\"))")` → marks every ready phase `active` and returns the set. See also: [The TQ language](/cairn/reference/tq-language) — [Views](/cairn/reference/views) — [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities). ### task_search Full-text ranked search over observations. Returns matching tasks with a snippet. Capability gate: `:cairn/read`. Signature: `task_search(query, limit?, task_id?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `query` | string | yes | Free-text search. Blank fails with `query is required.` Sanitized into an FTS5 MATCH expression. | | `limit` | integer | no | Maximum results. Defaults to 10, clamped to a maximum of 50. | | `task_id` | string | no | Restrict the search to one task's observations. | Returns: ranked rows (task slug plus a snippet), or `No matching observations for "".` when nothing matches. A query the engine cannot parse fails with `Could not parse the search query "".` Records no event. Semantics: results are BM25-ranked. The sanitizer is forgiving so identifiers and paths search literally: a token carrying any of the special characters `" ( ) : - . / ^ * +` is phrase-quoted (so `tools.lisp:45-78` and `depends-on` match as written), a trailing `*` stays a prefix query, and tokens join with implicit AND. A query that sanitizes to nothing usable returns the no-match line rather than erroring. Example: `task_search(query="prefix query*")` → ranked observations across tasks whose text contains a token starting with `prefix` and the token `query`. **Deferred scope.** A `scope` parameter exists in the runner (defaulting to observations) but is not in the registered wire schema, so tool-call search is deferred. Treat observation search as the advertised contract. See also: [The task graph](/cairn/concepts/the-task-graph) — [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs). ### task_get Read computed task state: status, description, parent, children, edges, metadata, and recent observations. Capability gate: `:cairn/read`. Signature: `task_get(task_id?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `task_id` | string | no | Task to read. Defaults to the current task. | Returns: a rendered state block, or `No task .` when the slug is unknown. Records no event. Semantics: the state is computed from the event log, not stored as a row of flags. The block carries the slug and status, then any description, parent, children, outgoing edges, metadata pairs, and up to five most recent observations. When more than five observations exist, a final pointer line — `… N earlier observations — call timeline with full=true, types=observation to read them all` — names how to read the rest. Each edge line prints the edge type first, then the destination slug. It is the same snapshot embedded at the top of [`task_bootstrap`](#task-bootstrap). Example: `task_get(task_id="2026-06-22-add-prefix-query-support")` → ```text 2026-06-22-add-prefix-query-support [active] add prefix query support parent: 2026-06-22-tune-search edges: depends-on 2026-06-22-tokenizer-fix recent: - prefix queries land; tokenizer fix is next ``` See also: [`timeline`](#timeline) — [`task_bootstrap`](#task-bootstrap) — [The task graph](/cairn/concepts/the-task-graph). ### timeline Recent events for a task, most recent first. Capability gate: `:cairn/read`. Signature: `timeline(task_id?, types?, full?, before_seq?, after_seq?, limit?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `task_id` | string | no | Task to read. Defaults to the current task. | | `types` | string | no | Comma-separated event-type filter, validated against the live vocabulary. Omitted or blank means every type. | | `full` | boolean | no | When true, render each event verbatim — a header line plus the untruncated multi-line body — instead of the one-line digest. Defaults to false. | | `before_seq` | integer | no | Exclusive upper seq bound; also the page-back cursor printed when output is truncated. | | `after_seq` | integer | no | Exclusive lower seq bound. | | `limit` | integer | no | Number of events. Defaults to 20. | Returns: a header line ` — last events` followed by one line per event (sequence number, event type, and a one-line digest of its payload). With `full=true`, each event instead renders as a header ` ( UTC)` and its verbatim body, block-indented when multi-line. Output is capped at a soft character ceiling: when the next event would exceed it, emission stops and prints `… truncated at chars; events remaining; pass before_seq= to continue`. Records no event. Semantics: where [`task_get`](#task-get) shows computed state, `timeline` shows the raw event stream that produced it — `observation`, `task.create`, `task.fork`, `task.link`, `task.sever`, `task.update-status`, `task.set-metadata`, `handoff.create`, and so on. By default each digest is collapsed to a single line and truncated; `full=true` renders bodies verbatim, making `timeline` the way to read a task's complete observation history. Filter to one or more event types with `types` — an unknown type errors with a nearest-match suggestion rather than silently returning nothing. Window or page with `before_seq` (exclusive) and `after_seq` (exclusive): feeding the `before_seq` cursor from a truncated page back into the next call returns the older events with no gap and no overlap. Read it to reconstruct how a task got where it is. Example: `timeline(task_id="2026-06-22-add-prefix-query-support", limit=3)` → ```text 2026-06-22-add-prefix-query-support — last 3 events 7 handoff.create prefix queries land; tokenizer fix is next → ... 6 task.update-status → active 5 observation bm25 ranking confirmed against t/cairn-search.lisp ``` Example: `timeline(types="observation", full=true)` renders every observation on the current task verbatim — full multi-line bodies, no 140-character digest cut — newest first. See also: [`task_get`](#task-get) — [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile). ### task_query Run a read-only query over the task graph and return text. The query is a single s-expression in cairn's query language (TQ). Capability gate: `:cairn/read`. Signature: `task_query(query)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `query` | string | yes | One TQ s-expression. A Lisp form, not a bare word — names and probes are wrapped in parens. | Returns: the rendered query result as text. Every form yields a value or a structured error, never a crash or a silent no-op. Records no event. Semantics: a query is a `SOURCE` optionally threaded through `STEP`s with `->`, for example `(-> (current) (:follow :phase-of) (:ids))`. Transformer steps compose node-set to node-set; shaper steps such as `(:select :slug :status)`, `(:count)`, and `(:ids)` terminate into a value. Pass a reflective form on its own to learn the surface: `(schema)` lists the sources, steps, predicates, and write forms, with steps and predicates carrying their kinds and argument signatures — call it first; `(views)` lists named views, `(fields)` the queryable fields, `(edges)` the edge types. These are sources that list names, so project with `(:select …)`. An unknown field, predicate, step, source, or edge errors with a nearest-match suggestion rather than a silent miss. Timestamps (`created-ts`, `updated-ts`, `status-ts`) are universal-time, so a date intent is one predicate — `(on :updated-ts "2026-06-24")`, `(since …)`, `(before …)` — and an integer operand to a numeric comparison against a timestamp is read as Unix seconds. Run a named view by wrapping it as `(query "plan")` or `(query "plan-frontier")`; a bare word like `plan` is a query error, since it is not a source form. The `!`-forms — mutations and view definitions — are refused here; use [`task_query_write`](#task-query-write). Example: `task_query(query="(-> (current) (:follow :phase-of) (:select :slug :status))")` → the current task's phase-of children with their statuses. See also: [The TQ language](/cairn/reference/tq-language) — [Views](/cairn/reference/views). ### task_bootstrap Orient on a task in one call: its computed state, neighbors, open handoffs, and recent observations. Capability gate: `:cairn/read`. Signature: `task_bootstrap(task_id?)` | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `task_id` | string | no | Task to orient on. Defaults to the current task; with neither, fails with `No task to bootstrap; pass task_id or select a task first.` An unknown slug fails with `No task .` and lists recent slugs. | Returns: the [`task_get`](#task-get) state block, then any open handoffs. Records no event. Semantics: `task_bootstrap` is the single-call session-start read. An explicit `task_id` switches the current pointer to that task — orienting on a task makes it current, so spawned sessions and the injected context agree on what is in focus. With no `task_id`, it orients on the current task and adopts it only when none is set; the pointer switches only after the task is found. Example: `task_bootstrap(task_id="2026-06-22-add-prefix-query-support")` → the task's state block and its open handoffs. See also: [The current task pointer](/cairn/concepts/the-current-task-pointer) — [The cairn-method](/cairn/concepts/the-cairn-method). #### The TQ query language TQ is the S-expression query language cairn evaluates over the [task graph](/cairn/concepts/the-task-graph). A query is one source form, or a source threaded through pipeline steps with `->`. It is read with `*read-eval*` disabled and walked, never evaluated, then lowered onto SQL over the task projection plus a few in-memory folds. Every result renders to text before it leaves the runner, so nothing but a string crosses the tool boundary, and every failure is a structured `Query error:` or `Parse error:` rather than a backtrace. Two tools run TQ: [`task_query`](/cairn/reference/tools#task-query) reads, and [`task_query_write`](/cairn/reference/tools#task-query-write) reads and writes. The language is the same on both; one dynamic gate decides whether a mutation form is allowed. ## Synopsis ```text QUERY := SOURCE | (-> SOURCE STEP...) SOURCE := (all) | (active) | (dormant) | (current) | (node "") | (query "") | (schema) | (views) | (fields) | (edges) | (define! "" QUERY) | (undefine! "") # write surface only STEP := ( ARG...) ``` A query is exactly one S-expression. The bare `->` head opens a pipeline whose first element is a source and whose rest are steps applied left to right. A step is a keyword form like `(:follow :phase-of)`; a bare keyword such as `:ids` is rejected — every operator is a form. A source carrying the wrong arity or an empty string argument errors rather than returning an empty set. ## Read versus write The `!`-suffix marks every mutation. `task_query` binds the gate off, so any `!`-form — a `(:set-status! ...)` step or a `(define! ...)` source — is refused: ```text Query error: Mutations need the task_query_write surface; task_query is read-only. ``` `task_query_write` binds the gate on and accepts them. The gate keys on the `!`-suffix and the step's `:mutation` kind, not on which tool is registered, so a write can never slip through the read surface. Inside set-operation operands and inside `(define! "name" Q)` the gate is forced off regardless of surface: you combine and define over selections, you do not mutate inside them. A mutation form within an operand is refused even under `task_query_write`. See [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities). ## Sources A source is a nullary producer of a node-set. A node is a slug paired with its hydrated properties. The four broad sources exclude the reserved `@`-prefixed namespace (the `@cairn` view node); real task slugs are date-prefixed, so nothing a user created is dropped, and `(node ...)` and `(current)` still address reserved nodes. | Form | Yields | Notes | | --- | --- | --- | | `(all)` | Every task. | Excludes the reserved namespace. | | `(active)` | Tasks with status `open`, `active`, or `blocked`. | The live half of the status enum. | | `(dormant)` | Tasks with status `completed` or `abandoned`. | The settled half. | | `(current)` | The current task. | Errors when no task is selected: `This query needs a current task; select one first.` | | `(node "substr")` | Tasks whose slug contains the substring. | Case-insensitive; a non-empty pattern is required. | | `(query "name")` | The named view resolved to its node-set. | An unknown name errors and enumerates the available views. | | `(schema)` | The source/step/write grammar as a node-set. | Reflective; see below. | | `(views)` | The named views, built-in and user, as a node-set. | Reflective. | | `(fields)` | The queryable fields and their value types. | Reflective. | | `(edges)` | The edge vocabulary and each type's class. | Reflective. | | `(define! "name" Q)` | Records `Q` as a named view; returns `Q`'s tasks. | Write surface only. | | `(undefine! "name")` | Removes a user view, restoring any built-in; returns nothing. | Write surface only. | `recent` is a **view**, not a source: reach it with `(query "recent")`. There is no `(recent)` source form. The list of resolvable view names lives in [Views](/cairn/reference/views). ## Steps A step transforms or shapes the running node-set. Transformers map a node-set to a node-set and compose; shapers terminate the pipeline into a final value (a projection, a group, a slug list, or a count); mutations write and return their input so they keep composing. A shaper must be last — a transformer after a terminal value errors with `This step expects a set of tasks, but the previous step produced a final value.` **Transformers** map a node-set to a node-set, so they chain. ### `(:follow EDGE)` The tasks one `EDGE` hop forward. Structural edges (`phase-of`, `forked-from`) traverse the parent foreign key; lateral edges (`depends-on`, `related`) traverse the edges table. The edge is validated first, so an unknown type errors even over an empty input — the edge is a property of the query, not of the data. ```lisp (-> (node "auth") (:follow :phase-of) (:ids)) ``` ### `(:back EDGE)` The tasks one `EDGE` hop backward — the inverse of `:follow`. From a forked child, `(:back :phase-of)` reaches its parent. ```lisp (-> (node "phase-3") (:back :phase-of) (:ids)) ``` ### `(:where PRED)` The tasks satisfying predicate `PRED`. A predicate that references a non-local field auto-enriches the set on demand; a predicate over a base field reads it directly without enrichment. See [Predicates](#predicates). ```lisp (-> (active) (:where (= :status "blocked")) (:ids)) ``` ### `(:sort FIELD [:asc|:desc])` The node-set ordered by `FIELD` — numeric when the field holds numbers, lexical otherwise. The direction is optional and defaults to `:desc`; pass `:asc` to ascend. A direction that is neither `:asc` nor `:desc` is a structured error with a suggestion, not a silently ignored argument. ```lisp (-> (active) (:sort :updated-ts)) ; most-recent first — the default (-> (active) (:sort :slug :asc) (:ids)) ; A→Z by slug ``` ```text Query error: :sort needs :asc or :desc; did you mean :asc? ``` ### `(:take N)` The first `N` tasks. `N` must be an integer; a negative `N` clamps to zero. ```lisp (-> (active) (:sort :obs-count) (:take 10)) ``` ### `(:enrich)` Add counts and promoted metadata (`:obs-count`, `:edge-count`, and any metadata keys) to each node, so fields beyond the hydrated base become readable and printable. ```lisp (-> (current) (:enrich)) ``` ### `(:union Q)` Tasks in the pipeline or in sub-query `Q`, deduplicated by slug. `Q` must itself yield a node-set, evaluated read-only. ```lisp (-> (active) (:union (dormant)) (:count)) ``` ### `(:intersect Q)` Tasks in both the pipeline and sub-query `Q`. ```lisp (-> (all) (:intersect (active)) (:count)) ``` ### `(:minus Q)` Pipeline tasks not in sub-query `Q`. ```lisp (-> (all) (:minus (active)) (:ids)) ``` ### `(:or-else Q)` The pipeline if it holds any task, else sub-query `Q`. Used by the `plan` view to fall back from a root's children to its siblings. ```lisp (-> (current) (:follow :phase-of) (:or-else (active))) ``` ### `(:closure EDGE...)` The forward transitive closure over one or more edge types, cycle-safe and bounded to depth 5. The starting nodes are excluded unless a cycle leads back to one. Each edge is validated eagerly. The `knowledge` view is `(:closure :phase-of :depends-on :related)`. ```lisp (-> (current) (:closure :phase-of :depends-on :related) (:enrich)) ``` **Shapers** end the pipeline in a final value and must come last. The text below each shaper is exactly what the runner returns. ### `(:select FIELD...)` Project the named fields; at least one field is required. Every selected field is validated against the [field vocabulary](/cairn/reference/edges-statuses-and-fields), so an unknown field is a structured error with a nearest-match suggestion rather than a silently dropped column: ```lisp (-> (all) (:select :updated-at)) ``` ```text Query error: Unknown field :updated-at; did you mean :updated-ts? ``` A projected field that is not already on the node — a count or a promoted metadata key — is enriched on demand, so it shows its value instead of nothing. The result is rectangular: every selected field prints on every row, an absent value as the empty-set glyph `∅`, and a [timestamp field](/cairn/reference/edges-statuses-and-fields#fields) as its raw universal-time integer followed by the decoded UTC date. ```lisp (-> (node "auth") (:select :display-name :status)) ``` ```text 1 task: - 2026-04-02-auth-rework display-name=Auth rework status=active ``` ```lisp (-> (node "rate-limit") (:select :slug :obs-count :updated-ts :parent)) ``` ```text 1 task: - 2026-04-05-rate-limit slug=2026-04-05-rate-limit obs-count=4 updated-ts=3984388200 (2026-04-05) parent=∅ ``` ### `(:group-by FIELD)` Bucket the node-set by `FIELD` value, largest bucket first. ```lisp (-> (active) (:group-by :status)) ``` ```text 2 groups: ## active (2) - 2026-04-02-auth-rework [Auth rework] (active) - 2026-04-05-rate-limit (active) ## blocked (1) - 2026-03-30-token-store (blocked) ``` ### `(:ids)` The slugs only, one per line, under a count line. ```lisp (-> (active) (:ids)) ``` ```text 3 tasks: - 2026-03-30-token-store - 2026-04-02-auth-rework - 2026-04-05-rate-limit ``` The count line pluralizes: a one-task result reads `1 task:`, and an empty result is the single line `No matching tasks.` ### `(:count)` The cardinality, as a bare number with no surrounding text. ```lisp (-> (all) (:count)) ``` ```text 2 ``` **Mutations** are transformers that also write. A mutation records one event per real task through the durable [event log](/cairn/concepts/events-projection-and-reconcile) and returns its input set, so `(:count)` after it reports the size of the set it ran over — equal to the number of real tasks touched when no synthesized nodes are present. A synthesized node — a grammar node from a reflective source — carries no task id and is passed over, never minting a phantom, yet stays in the returned set. Events are append-only and their projection effects converge, so re-running a mutation is safe. Every mutation requires the `task_query_write` surface. ### `(:set-status! STATUS)` Set each task's status. `STATUS` must be one of `open`, `active`, `completed`, `abandoned`, `blocked`. ```lisp (-> (active) (:set-status! "completed") (:count)) ``` ```text 3 ``` ### `(:set! :key "value")` Set a metadata field on each task. The key is a field keyword; it refuses `:slug` and `:status` (status has its own step). ```lisp (-> (current) (:set! :objective "ship it")) ``` ### `(:link! :edge "target")` Add an edge from each task to the constant `target` slug. `:edge` is one of `phase-of`, `depends-on`, or `related` — the same closed enum the [`task_link`](/cairn/reference/tools) tool takes. A `depends-on` or `related` link lands in the edges table; a `phase-of` link is structural and folds into the parent foreign key rather than the lateral table, exactly as the [edges reference](/cairn/reference/edges-statuses-and-fields) describes. A task equal to the target is skipped — a task does not edge to itself. ```lisp (-> (node "phase-2") (:link! :depends-on "phase-1")) ``` ### `(:unlink! :edge "target")` Remove the edge from each task to `target`. `:edge` is one of `phase-of`, `depends-on`, or `related`, matching `(:link!)`. ```lisp (-> (node "phase-2") (:unlink! :depends-on "phase-1")) ``` ## Predicates A predicate is the argument to `(:where ...)` and to quantifiers. It is a form of `(slug props)` and composes with boolean combinators. | Form | Meaning | | --- | --- | | `(= LHS v)` | `LHS` equals literal `v`. `LHS` is a field keyword or `(count TRAV)`. | | `(has :field)` | `:field` is present and non-null on the node. | | `(matches :field "substr")` | `:field`'s value contains the case-insensitive substring. | | `(> LHS n)` | `LHS` is greater than real number `n`. | | `(< LHS n)` | `LHS` is less than `n`. | | `(>= LHS n)` | `LHS` is at least `n`. | | `(on :ts "YYYY-MM-DD")` | timestamp field `:ts` falls on the given UTC day. | | `(since :ts "YYYY-MM-DD")` | `:ts` is on or after the given UTC day. | | `(before :ts "YYYY-MM-DD")` | `:ts` is before the given UTC day. | | `(and P...)` | Every sub-predicate holds. | | `(or P...)` | Some sub-predicate holds. | | `(not P)` | `P` does not hold. | | `(all TRAV P)` | `P` holds for every task one `TRAV` hop away. | | `(any TRAV P)` | `P` holds for some task one `TRAV` hop away. | | `(none TRAV P)` | `P` holds for no task one `TRAV` hop away. | In a quantifier, `TRAV` is `(:follow EDGE)` or `(:back EDGE)`. The traversal's edge is validated eagerly, so an ill-typed quantifier errors even over an empty focus set. Over a focus node with no neighbours, `all` and `none` are vacuously true and `any` is false. Numeric comparisons are type-checked. The left side of `>`, `<`, or `>=` must be a numeric field (one of `created-ts`, `updated-ts`, `status-ts`, `obs-count`, `edge-count`) or `(count TRAV)`, and the operand a real number; comparing a text field numerically is an error, not a silent empty match. `(count TRAV)` is the cardinality of a relative traversal, usable wherever a numeric left side is. ```lisp (-> (node "release") (:where (> (count (:follow :depends-on)) 1)) (:ids)) ``` The full field vocabulary and which fields are textual versus numeric is in [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields). ### Dates and timestamps `created-ts`, `updated-ts`, and `status-ts` are `:timestamp` fields: CL universal-time, an integer count of seconds since `1900-01-01 UTC`. They order as integers and read as dates. The `on` / `since` / `before` predicates window the clock by a calendar day so a date intent is one predicate, not a reverse-engineered range. Each parses a strict `YYYY-MM-DD` operand at UTC midnight; `on` matches the 24-hour day `[day, day+1)`, `since` matches `≥ day`, and `before` matches `< day`. ```lisp (-> (active) (:where (on :updated-ts "2026-06-24")) (:ids)) (-> (active) (:where (since :created-ts "2026-06-01")) (:count)) ``` A date predicate is gated to timestamp fields, and a malformed date is a structured error rather than a silent empty result: ```text Query error: on needs a timestamp field; :status is text. Query error: "not-a-date" is not a YYYY-MM-DD date. ``` Because a timestamp orders as an integer, the numeric predicates compare it too. An **integer** operand to `>`, `<`, or `>=` against a timestamp field is read as Unix seconds and lifted onto the same universal-time clock the write path uses, so a Unix-epoch literal lands where you mean it instead of below every value. `(< :updated-ts 2500000000)` compares against the lifted instant, not the raw integer. ### Unknown names suggest a fix Every dispatched name — a field, a predicate, a step, a source, an edge type — is validated against its vocabulary, and an unknown one is a structured error carrying the nearest match within a small edit distance, or the full list when nothing is close. The query never silently misfires on a typo. ```text Query error: Unknown predicate matchez; did you mean matches? Query error: Unknown step :zelect; did you mean :select? Query error: Unknown source recent; known: active, all, current, dormant, edges, fields, node, query, schema, views. ``` ## Reflective forms `(schema)`, `(fields)`, `(edges)`, and `(views)` are sources that render the language's own vocabularies as node-sets in the same carrier task queries produce, so every step composes over them. The slugs are bare names, never date-prefixed, so they never collide with a task, and a task traversal over a grammar node is an honest empty set rather than an error. Each list is generated from the registry the interpreter dispatches on, so the description cannot drift from behaviour. | Source | Each node carries | | --- | --- | | `(schema)` | `:category` (`source`/`step`/`predicate`/`write`); `:kind` on steps (`transformer`/`shaper`/`mutation`) and predicates (`leaf`/`combinator`/`quantifier`); `:signature` (the machine-readable argument contract) on steps and predicates; and `:doc`. | | `(fields)` | `:origin` (`declared` or `metadata`), `:type` (`text`, `number`, or `timestamp`), and on a timestamp field `:unit` and `:epoch`. | | `(edges)` | `:class` (`structural` or `lateral`). | | `(views)` | `:origin` (`builtin` or `user`) and `:source` (the view's TQ text). | `(schema)` lists predicates alongside sources and steps, so the predicate vocabulary — including the date predicates and their signatures — is discoverable from the one call. There is no `(help)` source; `(schema)` is the canonical entry point to the grammar. Probe the grammar with the algebra: ```lisp (-> (schema) (:where (= :kind "shaper")) (:ids)) (-> (schema) (:where (= :category "predicate")) (:select :kind :signature)) (-> (fields) (:where (= :type "timestamp")) (:ids)) (-> (edges) (:where (= :class "structural")) (:ids)) (-> (views) (:where (= :origin "builtin")) (:ids)) ``` The first lists the shapers by their bare names, sorted with the rest of the carrier: ```text 4 tasks: - count - group-by - ids - select ``` The second reads the predicate vocabulary with its kinds and signatures — for example the three date predicates share the signature `field-ts lit-date`, so `(-> (schema) (:where (= :signature "field-ts lit-date")) (:ids))` recovers `before`, `on`, and `since`. Reflective sources run on the read surface — they describe, never mutate — and stay total. Use `(:select ...)` to project a field, for example `(-> (schema) (:where (= :category "step")) (:select :doc))`. ## Worked examples Each program is one full query. Read-surface examples run under either tool; write-surface examples require [`task_query_write`](/cairn/reference/tools#task-query-write). The text fence under each query is the runner's actual output. ```lisp (-> (active) (:where (not (has :parent)))) ``` ```text 2 tasks: - 2026-04-02-auth-rework [Auth rework] (active) - 2026-04-05-rate-limit (active) ``` Read. Active tasks with no parent — the `active-roots` view: every live root in the graph. With no `(:ids)` or `(:select ...)` shaper the result is the node-set itself, one `- slug [display] (status)` line per task; `:parent` is a base field, so the predicate reads it without enrichment and the lines carry no `obs=`/`edges=` counts. Add `(:enrich)` before the implicit render to print them. ```lisp (-> (current) (:follow :phase-of) (:ids)) ``` ```text 3 tasks: - 2026-04-02-auth-rework-phase-1 - 2026-04-02-auth-rework-phase-2 - 2026-04-02-auth-rework-phase-3 ``` Read. The phases directly under the current task. ```lisp (query "plan-frontier") ``` Read. The phases of the current plan that are not done and whose every `depends-on` dependency is settled — the ready work. `(query "frontier")` is an alias. ```lisp (-> (node "auth") (:where (matches :description "oauth")) (:ids)) ``` Read. Tasks whose slug contains `auth` and whose description mentions `oauth`. ```lisp (-> (active) (:union (dormant)) (:count)) ``` ```text 2 ``` Read. The size of the whole task set, by the status partition. ```lisp (-> (current) (:closure :phase-of :depends-on :related) (:enrich)) ``` Read. The transitive neighbourhood of the current task across every edge — the `knowledge` view. ```lisp (-> (active) (:set-status! "completed") (:count)) ``` ```text 3 ``` Write. Complete every active task and report how many were touched; re-running over the now-empty active set touches nothing and returns `0`. ```lisp (-> (node "phase-2") (:link! :depends-on "phase-1")) ``` Write. Add a `depends-on` edge from each `phase-2` task to `phase-1`. The link step returns its input set, so the rendered output is the touched `phase-2` nodes. ```lisp (define! "blocked-work" (-> (active) (:where (= :status "blocked")))) ``` Write. Record a user view named `blocked-work`; it then resolves on the read surface via `(query "blocked-work")` and shadows any built-in of the same name. `(undefine! "blocked-work")` removes it. ## Related - [Tools](/cairn/reference/tools#task-query) — the `task_query` and `task_query_write` tool surface that runs TQ. - [Views](/cairn/reference/views) — the built-in named views, each with its TQ source text. - [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields) — the closed enums TQ predicates and steps validate against. - [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities) — why the `!`-gate is one dynamic boundary. #### Built-in and user-defined views A view is a named query: a label bound to a piece of [TQ source text](/cairn/reference/tq-language) that resolves to a set of tasks when you call `(query "name")`. cairn ships ten built-in views, expressed in TQ itself. They are the language's shipped vocabulary — `plan-frontier`, `leaf-tasks`, `stale-phases`, and the rest — and you read them on the read surface through [`task_query`](/cairn/reference/tools#task-query) or define your own through [`task_query_write`](/cairn/reference/tools#task-query-write). Each built-in is data, not tool surface. The view's body is ordinary TQ, so you can read the definition with `(-> (views) (:where (= :origin "builtin")) (:select :source))`, copy it, and adapt it. A view defined under your own name shadows the built-in of the same name; remove the user view and the built-in reappears. ## Reading a view Resolve any view by name with the `(query "name")` source: ```text task_query (query "plan-frontier") ``` The result renders as a task list — one line per task, slug first, then display name, status, and any counts the view enriched: ```text 2 tasks: - 2026-06-20-write-the-parser (open) obs=3 edges=2 - 2026-06-20-wire-the-cli (open) obs=1 edges=1 ``` `(query "name")` is a read. A view defined on the write surface still resolves on the read surface, because reading a view is a read regardless of how the view was created. Naming a view that does not exist is a structured error that enumerates every resolvable name — both built-in and user-defined: ```text Query error: Unknown named query plan-fronteir. Available: active-roots, orphans, leaf-tasks, stale-phases, plan, plan-frontier, frontier, recent, busy, hub-tasks, knowledge. ``` ## The ten built-in views The table lists every shipped view with its TQ source text exactly as cairn defines it. `frontier` is an alias — its body is `(query "plan-frontier")` — so the eleven accepted names resolve to ten distinct queries. | View | TQ source | Yields | | --- | --- | --- | | `active-roots` | `(-> (active) (:where (not (has :parent))))` | Live tasks with no parent — the roots of every open plan. | | `orphans` | `(-> (all) (:where (= :edge-count 0)))` | Tasks with no edges in any direction: unparented, unlinked, unforked. | | `leaf-tasks` | `(-> (active) (:where (= (count (:follow :phase-of)) 0)))` | Live tasks with no phase children — the bottom of the plan tree. | | `stale-phases` | `(-> (active) (:where (any (:back :phase-of) (or (= :status "completed") (= :status "abandoned")))))` | Live phases whose parent has already settled, a sign the plan moved on without them. | | `plan` | `(-> (current) (:follow :phase-of) (:or-else (-> (current) (:back :phase-of) (:follow :phase-of))) (:enrich))` | The phases of the current task's plan, enriched with counts. Current-scoped. | | `plan-frontier` | `(-> (query "plan") (:where (and (not (or (= :status "completed") (= :status "abandoned"))) (all (:follow :depends-on) (or (= :status "completed") (= :status "abandoned"))))))` | The ready subset of `plan`: unsettled phases whose every `depends-on` dependency is settled. | | `frontier` | `(query "plan-frontier")` | Alias for `plan-frontier`. | | `recent` | `(-> (active) (:sort :updated-ts) (:take 20))` | The twenty most recently touched live tasks. | | `busy` | `(-> (active) (:sort :obs-count) (:take 20))` | The twenty live tasks with the most observations. | | `hub-tasks` | `(-> (active) (:sort :edge-count) (:take 20))` | The twenty live tasks with the most edges — the connectors in the graph. | | `knowledge` | `(-> (current) (:closure :phase-of :depends-on :related) (:enrich))` | The transitive neighborhood of the current task across all three edge types, cycle-safe and depth-bounded. | Three of these views read the `(current)` source — `plan`, `frontier`/`plan-frontier`, and `knowledge` — so they resolve against [the current task pointer](/cairn/concepts/the-current-task-pointer) and error when no task is selected. The rest range over `(active)` or `(all)` and resolve the same way in every session. The [TQ sources](/cairn/reference/tq-language#sources) table defines each producer. ### Built-in source forms in TQ Each built-in is a complete TQ program, and every piece it draws on is documented in the [TQ language reference](/cairn/reference/tq-language). The [sources](/cairn/reference/tq-language#sources) are `(active)`, `(all)`, and `(current)`. The [steps](/cairn/reference/tq-language#steps) are `(:where)`, `(:follow)`, `(:back)`, `(:sort)`, `(:take)`, `(:or-else)`, `(:closure)`, and `(:enrich)`. Inside a predicate they reach across edges with the `(any TRAV P)` and `(all TRAV P)` quantifiers and count with the `(count TRAV)` field-expression. `plan-frontier` composes over `plan` by name, and `frontier` composes over `plan-frontier` by name, so a view defined in terms of another view resolves transitively. A view defined in terms of itself is a structured error, not an unbounded loop. Define a view named `self` whose body is `(query "self")` and resolving it reports the cycle rather than recursing — `self` in the error text is the offending view name: ```text Query error: View self is defined in terms of itself. ``` ## `recent` is a view, not a source `recent` is a view name, so you read it with `(query "recent")`. `(recent)` is not a source form. The query language has exactly ten sources — `(all)`, `(active)`, `(dormant)`, `(current)`, `(node "substr")`, `(query "name")`, `(schema)`, `(views)`, `(fields)`, `(edges)` — and `recent` is not among them. Calling `(recent)` as if it were a source is an unknown-source error that lists the ten known sources: ```text Query error: Unknown source recent; known: active, all, current, dormant, edges, fields, node, query, schema, views. ``` The same holds for every other view name: `(plan)`, `(orphans)`, and `(busy)` are not sources. Reach a view only through `(query "name")`. ## Defining your own views `define!` and `undefine!` are write forms — they record an event and are accepted only on the [`task_query_write`](/cairn/reference/tools#task-query-write) surface. The read surface refuses them: ```text Query error: Mutations need the task_query_write surface; task_query is read-only. ``` ### `define!` ```text task_query_write (define! "blocked-phases" (-> (active) (:where (= :status "blocked")))) ``` `(define! "name" Q)` validates `Q` read-only, records its source text as a `view.define` event, and returns `Q`'s tasks — so `define!` is interchangeable with the query it names. The view name must be a non-empty string. The query `Q` is evaluated read-only even on the write surface: you define over a selection, you do not mutate inside the definition. After the call, `(query "blocked-phases")` resolves on either surface. ### `undefine!` ```text task_query_write (undefine! "blocked-phases") ``` `(undefine! "name")` records a `view.undefine` event and returns no tasks. It removes the user view of that name. If a built-in of the same name exists, that built-in becomes visible again. ### Shadowing a built-in A user view of a built-in's name shadows it in the one resolver. Define `orphans` over `(dormant)`, and `(query "orphans")` now returns settled tasks instead of edgeless ones; `undefine!` it and the built-in `(-> (all) (:where (= :edge-count 0)))` resolves again: ```text task_query_write (define! "orphans" (dormant)) task_query (query "orphans") # now resolves to your definition task_query_write (undefine! "orphans") task_query (query "orphans") # the built-in is back ``` A view is a fold of `view.define`/`view.undefine` events, so a rebuild reconstructs your views exactly. See [events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) for how the view vocabulary replays from the durable log. ## Listing every view The `(views)` [source](/cairn/reference/tq-language#sources) produces every resolvable view as a queryable node-set. Each view node carries an `:origin` of `builtin` or `user` and a `:source` field holding its TQ text, so you can filter and project the catalogue with the algebra itself: ```text task_query (-> (views) (:where (= :origin "builtin")) (:ids)) ``` ```text task_query (-> (views) (:where (= :origin "user")) (:select :source)) ``` A built-in shadowed by a user view of the same name reports once, as `user` — `(views)` reflects what resolves, not what ships. ## Related - [TQ language](/cairn/reference/tq-language) — the sources, steps, and predicates every view is written in. - [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) — why the frontier is the ready subset of a plan. - [Tools](/cairn/reference/tools#task-query) — the `task_query` and `task_query_write` wire surface. #### Edges, statuses, and fields cairn's task graph is built from three closed vocabularies: the edge types that connect tasks, the statuses a task moves through, and the fields a query reads. Each is a small fixed set defined in one place in the source, so a tool, a query predicate, and a stored `CHECK` constraint can never disagree about what is legal. This page is the catalogue of those sets. It also covers the reflective sources — `(edges)`, `(fields)`, `(schema)`, `(views)` — that let a query read the catalogue back from the running store. A field absent from the typed set is not an error: it is open metadata, set with [`task_set_metadata`](/cairn/reference/tools#task-set-metadata) and read as text. The closed sets below are the ones the language understands well enough to validate, classify, and type-check. ## Edge types An edge connects two tasks. cairn stores edges two ways, and which way an edge is stored decides how a query traverses it. A **lateral** edge lives in the `edges` table as an explicit row from a source task to a target task. A **structural** edge names the task fibration — a child's single parent — and folds into the child's `parent_task_id`, never the `edges` table. A query traverses a lateral edge by joining the `edges` table and a structural edge by walking the parent foreign key. The surface verb is the same ([`:follow`](/cairn/reference/tq-language#steps) / `:back`); the storage and the cardinality differ. The [task graph](/cairn/concepts/the-task-graph) page covers why the two are stored apart. | Edge type | Class | Accepted by tools | Notes | | --- | --- | --- | --- | | `phase-of` | structural | yes | A phase belongs to its parent plan. Folded into the child's `parent_task_id`; a task has at most one parent. Default `edge_type` for [`task_fork`](/cairn/reference/tools#task-fork). | | `depends-on` | lateral | yes | This task waits on its target. The ordering edge the frontier reads to decide readiness. | | `related` | lateral | yes | A loose association with no ordering or ownership meaning. | | `forked-from` | structural | no | A structural alias of the parent pointer that the traversal layer recognizes; in the store it is indistinguishable from `phase-of` (both fold into `parent_task_id`). It is never the edge a fork writes — a fork records `phase-of` by default — and never a value you pass as `edge_type`. | ### The tool-accepted vocabulary [`task_link`](/cairn/reference/tools#task-link), [`task_sever`](/cairn/reference/tools#task-sever), and [`task_fork`](/cairn/reference/tools#task-fork) accept exactly three `edge_type` values: `phase-of`, `depends-on`, `related`. The check is case-insensitive — `Depends-On` is accepted and lowercased — and anything outside the set is refused before the write: ```text "blocks" is not a valid edge type; expected one of phase-of, depends-on, related. ``` `blocks` and `blocked-by` look like they should work, and they read cleanly, but they are **not** part of the tool-accepted vocabulary. Internally cairn projects both onto `depends-on` while preserving the original name in a tag; that projection is an implementation detail of how a raw edge is normalized, not a value the tools advertise. Pass `depends-on` directly. ### Traversal classes A query traversal classifies its edge keyword the same way before it runs, and an unrecognized edge is a structured error rather than a silently empty result — the edge is a property of the query, not of the data. The error carries the nearest known type, or the full list when nothing is close: ```text Unknown edge type spawned-by; known: :depends-on, :forked-from, :phase-of, :related. ``` Note the traversal vocabulary is wider than the tool vocabulary: a query may traverse `forked-from` (and `phase-of`) because both name the structural fibration, even though only `phase-of` is a tool-accepted `edge_type`. The mutation steps [`:link!`](/cairn/reference/tq-language#steps) / `:unlink!` narrow back to the three tool-accepted edge_type values (`phase-of`, `depends-on`, `related`), mirroring the write tools. ## Statuses Every task carries exactly one of five statuses. The set is closed and the SQLite `CHECK` constraint mirrors it, so an invalid status cannot reach the store. | Status | Half | Meaning | | --- | --- | --- | | `open` | active | Created, not yet started. | | `active` | active | In progress. | | `blocked` | active | Stalled on an external condition; still live work. | | `completed` | dormant | Finished and settled. | | `abandoned` | dormant | Settled without completion; will not be resumed. | The status enum partitions into an **active** half (`open`, `active`, `blocked`) and a **dormant** half (`completed`, `abandoned`). This is the partition the query sources read: [`(active)`](/cairn/reference/tq-language#sources) yields the live half and `(dormant)` yields the settled half. A plan's [frontier](/cairn/concepts/plans-phases-and-the-frontier) is then ready precisely when a phase is not dormant and all of its `depends-on` targets are. The partition is checked at load time against the full enum, so a status can never be live and settled at once, nor fall outside both halves. [`task_update_status`](/cairn/reference/tools#task-update-status) lowercases its `status` argument and validates it against the set; anything else is refused: ```text "done" is not a valid status; expected one of open, active, completed, abandoned, blocked. ``` `completed` and `abandoned` are not terminal in the data — a task is reopened by setting it `active` again, and the `reopen` flag on `task_update_status` is shorthand for exactly that. They are terminal only in the sense that the dormant half drops out of the active sources until you move it back. ## Fields A field is a named, typed value a query reads off a task. Eleven fields are declared with a type; the type is what lets a numeric predicate refuse a textual field instead of comparing garbage, and a date predicate refuse anything but a clock. | Field | Type | Source | | --- | --- | --- | | `slug` | text | The task's date-prefixed identifier; the node's own name. | | `status` | text | The current status (see above). | | `description` | text | The freeform description set at creation. | | `depot` | text | The depot the task belongs to. | | `parent` | text | The slug of the structural parent, or absent for a root. | | `display-name` | text | A human-readable label; defaults to the slug. | | `created-ts` | timestamp | Creation time, universal-time seconds. | | `updated-ts` | timestamp | Last-touched time, universal-time seconds. | | `status-ts` | timestamp | Time of the last status change. | | `obs-count` | number | Number of observations recorded on the task. | | `edge-count` | number | Number of edges incident to the task (lateral plus structural). | The six **text** fields are `slug`, `status`, `description`, `depot`, `parent`, `display-name`. The two **number** fields are `obs-count` and `edge-count`. The three **timestamp** fields are `created-ts`, `updated-ts`, and `status-ts`. The type drives predicate type-checking: `(> :obs-count 3)` compiles because `obs-count` is numeric, while `(> :status 1)` is rejected as ill-typed before it touches a row: ```lisp (-> (active) (:where (> :status 1))) ``` ```text Query error: > needs a numeric field; :status is text. ``` A **timestamp** is CL universal-time — an integer count of seconds since the epoch `1900-01-01 UTC`. It is numeric-comparable, so it orders and compares as an integer (`(> :updated-ts 0)` is well-typed), and it is the only type the [date predicates](/cairn/reference/tq-language#dates-and-timestamps) `on` / `since` / `before` accept, letting `(on :updated-ts "2026-06-24")` express a calendar-day intent directly. When projected with [`:select`](/cairn/reference/tq-language#steps), a timestamp renders as its raw integer and its decoded UTC date — `updated-ts=3984388200 (2026-04-05)` — so the integer clock stays legible. The unit and epoch are not buried in this page: `(fields)` carries them on every timestamp field, below. Any field not in this table is **open metadata** — a key written with `task_set_metadata` or the [`:set!`](/cairn/reference/tq-language#steps) step. It is read on demand and treated as text, so `(matches :owner "mika")` works against a metadata key cairn has never heard of; only the declared eleven get a numeric or timestamp type. The counts `obs-count` and `edge-count`, and any promoted metadata, are added by enrichment ([`:enrich`](/cairn/reference/tq-language#steps)), which a query inserts automatically when a step references a field that is not already local. ## Reflective sources The three vocabularies above are queryable from the running store, not only documented here. Four sources synthesize the catalogue into the same node-set carrier that task queries produce, so every [TQ](/cairn/reference/tq-language) step composes over them for free. The names they yield are bare (never date-prefixed), so they never collide with a real task. Each source is generated from the registry the interpreter dispatches on, so the readout cannot drift from behavior. | Source | Yields | Per-node props | | --- | --- | --- | | `(edges)` | The edge vocabulary, one node per type | `:class` — `structural` or `lateral` | | `(fields)` | The declared fields plus any live metadata keys | `:origin` (`declared` / `metadata`), `:type` (`text` / `number` / `timestamp`), and `:unit` + `:epoch` on a timestamp | | `(views)` | The named [views](/cairn/reference/views), built-in and user | `:origin` (`builtin` / `user`), `:source` (TQ text) | | `(schema)` | The combinator grammar: every source, step, predicate, and write form | `:category` (`source` / `step` / `predicate` / `write`), `:kind` (on steps and predicates), `:signature` (on steps and predicates), `:doc` | `(edges)` lists all four known edge types — the three tool-accepted plus `forked-from` — because the traversal surface understands all four. To recover only the tool-accepted lateral set, filter on the class: ```lisp (-> (edges) (:where (= :class "lateral")) (:ids)) ``` ```text 2 tasks: - depends-on - related ``` The same algebra reads the fields. List every numeric field, or every timestamp field: ```lisp (-> (fields) (:where (= :type "number")) (:ids)) ``` ```text 2 tasks: - edge-count - obs-count ``` ```lisp (-> (fields) (:where (= :type "timestamp")) (:ids)) ``` ```text 3 tasks: - created-ts - status-ts - updated-ts ``` The clock is self-describing: project a timestamp field's `:unit` and `:epoch` instead of reverse-engineering them from raw values. ```lisp (-> (fields) (:where (= :type "timestamp")) (:select :type :unit :epoch)) ``` ```text 3 tasks: - created-ts type=timestamp unit=seconds epoch=1900-01-01 UTC (CL universal-time) - status-ts type=timestamp unit=seconds epoch=1900-01-01 UTC (CL universal-time) - updated-ts type=timestamp unit=seconds epoch=1900-01-01 UTC (CL universal-time) ``` `(fields)` also lists the **open metadata** keys live in the store, each tagged `:origin "metadata"` (declared fields are `:origin "declared"`), so a query discovers the metadata vocabulary it can filter and project without reading any code — `(-> (fields) (:where (= :origin "metadata")) (:ids))`. `(schema)` describes the grammar itself, including the reflective sources — it lists `edges`, `fields`, `views`, and `schema` among its source nodes, and it lists the predicate vocabulary (each predicate tagged `:category "predicate"` with its `:kind` and `:signature`) alongside the sources and steps. That makes the language self-documenting: an agent that has the `(schema)` source can discover the rest of the surface without leaving the query, which is the same loop these docs serve for a human reader. There is no `(statuses)` source; the status partition is documented above and surfaces through `(active)` / `(dormant)` rather than a reflective list. ## Related - [Tools](/cairn/reference/tools) — the writers that consume these enums: `task_link`, `task_fork`, `task_update_status`, `task_set_metadata`. - [The TQ language](/cairn/reference/tq-language) — the sources, steps, and predicates that read fields and traverse edges. - [Views](/cairn/reference/views) — the built-in named queries phrased over these vocabularies. - [The task graph](/cairn/concepts/the-task-graph) — why structural and lateral edges are stored differently. - [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) — how `phase-of`, `depends-on`, and the status partition combine into readiness. ### Commands & Serving #### Slash commands cairn registers six slash commands when it loads under a kli host: `/observe`, `/handoff`, `/task`, `/tasks`, `/workon`, and `/where`. They are the hand-typed surface over the same task graph the [MCP tools](/cairn/reference/tools) reach over the wire: a line you type at the prompt instead of a tool the model calls. Two of them, `/observe` and `/handoff`, do something an agent can also do for itself. The other four are operator conveniences the model never sees. The commands depend on a host that offers a command surface. cairn registers them only against a `:commands/v1` provider. A headless run with no such provider registers nothing, so there are no slash commands when cairn is served straight over [`kli mcp-serve cairn`](/cairn/cli/mcp-serve). For that path, use the tools directly. A command's first word names it; everything typed after the first word is one free-text tail passed to the runner. `/where` takes no tail. ## The six commands | Command | Syntax | Effect | Model-visible | MCP analogue | | --- | --- | --- | --- | --- | | `/observe` | `/observe ` | Record an observation on the current task. | yes | [`observe`](/cairn/reference/tools#observe) | | `/handoff` | `/handoff [guidance]` | Compose and record a resumable handoff for the current task. | yes | [`handoff`](/cairn/reference/tools#handoff) (the turn authors the body; the tool only scaffolds) | | `/task` | `/task [id]` | Show the current task, or select one by id. | no | partial — pointer read/set, like [`task_bootstrap`](/cairn/reference/tools#task-bootstrap) without the open-handoffs readout | | `/tasks` | `/tasks [query]` | List recent tasks, or run a task-graph query. | no | [`task_query`](/cairn/reference/tools#task-query) | | `/workon` | `/workon [id]` | Select a task and seed a re-entry turn. | no | none | | `/where` | `/where` | Show the resolved database path, project, and how it was resolved. | no | none | **Model visibility** is the `:model-visible` metadata on each command. `/observe` and `/handoff` carry no such metadata, so their effect rides in the conversation the model reads. `/task`, `/tasks`, `/workon`, and `/where` are registered with `:model-visible nil`: they run and print to you, but their echo is hidden from the model. That split lets an operator inspect or steer the graph without spending the readout as context. See [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities) for the boundary the operator surfaces sit behind. A command and its MCP analogue are not the same call. `/observe` invokes the `observe` tool unchanged. `/handoff` and `/workon` do work the tools cannot: `/handoff` drives an authoring turn and `/workon` injects a deterministic block, and both patch the transcript. The other operator surfaces — `/task`, `/tasks`, and `/where` — have their own output shapes. The sections below give each command's exact behavior and the verbatim text it prints. In the example blocks, a leading `>` marks the line you type; every other line is what cairn prints back. ## /observe Record an observation on the current task. Syntax: `/observe ` `/observe` is a thin wrapper over the [`observe`](/cairn/reference/tools#observe) tool: it passes your tail as the `text` argument, invokes the tool, and returns the tool's result unchanged. The observation lands on the current task; it does not move the current-task pointer. On success the result is the tool's own line: ```text > /observe rotation test still red at the 30-day boundary Observed on 2026-06-22-auth-rotation. ``` With no tail, the command fails before reaching the tool: ```text > /observe Usage: /observe ``` `/observe` is model-visible, so the observation and its confirmation enter the conversation. From a non-kli MCP client, call the [`observe`](/cairn/reference/tools#observe) tool directly — same effect, same return text. ## /handoff Compose and record a resumable handoff for the current task. Syntax: `/handoff [guidance]` `/handoff` is richer than the [`handoff`](/cairn/reference/tools#handoff) tool, but the command itself does little. Its only deterministic act is to start one interactive authoring turn and reply `Composing a handoff...`. The session is then handed an instruction to compose a one-line summary, call the `handoff` tool to scaffold and record the document, read the scaffold back, and overwrite it with a rich body. Those steps are what the turn is told to do, not guarantees the command performs. The tool, by contrast, only scaffolds a deterministic skeleton and records the event; it never writes prose. Any tail you pass is guidance threaded into that authoring instruction: ```text > /handoff Composing a handoff... > /handoff focus on the rotation regression; the refresh path is done Composing a handoff... ``` Because it drives an authoring turn, `/handoff` needs an interactive session. Headless, with no agent-session service, it declines and writes nothing: ```text Handoff authoring needs an interactive session. ``` From a non-kli MCP client, the [`handoff`](/cairn/reference/tools#handoff) tool is the right call: it scaffolds the skeleton and you supply the body yourself. ## /task Show the current task, or select one by id. Syntax: `/task [id]` With no tail, `/task` prints the current task's computed state: its status, description, parent, children, edges, metadata, and recent observations. With no current task set, it says so and offers recent slugs: ```text > /task No current task. Recent: 2026-06-22-auth-rotation, 2026-06-21-search-index ``` With a tail, `/task ` sets the current-task pointer to that slug and prints the task's state. An unknown id leaves the pointer untouched and lists recent slugs instead: ```text > /task 2026-06-22-auth-rotation Current task set to 2026-06-22-auth-rotation. 2026-06-22-auth-rotation [active] …computed task state… > /task no-such-task No task no-such-task. Recent: 2026-06-22-auth-rotation, 2026-06-21-search-index ``` `/task` is hidden from the model: it reads or moves the pointer without entering the conversation. The model's own way to orient and adopt a task is [`task_bootstrap`](/cairn/reference/tools#task-bootstrap), which adds open handoffs; `/task` is the bare pointer surface. See [The current task pointer](/cairn/concepts/the-current-task-pointer). ## /tasks List recent tasks, or run a task-graph query. Syntax: `/tasks [query]` `/tasks` runs the [TQ query language](/cairn/reference/tq-language) against the live graph and renders the result. With no tail, it runs the built-in `recent` view. With a tail, it interprets your tail as a TQ program, read with `*read-eval*` disabled so the form is walked and never evaluated. The query runs scoped to the current task, so current-relative forms like the `(current)` source and the current-scoped views `(query "plan")` and `(query "plan-frontier")` resolve against the pointer. ```text > /tasks …the recent view… > /tasks (-> (query "plan-frontier") (:ids)) …ready phases… ``` Three failure channels carry the underlying message verbatim, which is what lets you correct the query in place: ```text Parse error: Query error: The query could not be executed. ``` `/tasks` is hidden from the model. It is read-only: it runs the read query engine, so it does not accept TQ's `!`-forms — the write source `define!` and the mutation steps such as `:set-status!`. To define a view or mutate the graph through TQ, use the [`task_query_write`](/cairn/reference/tools#task-query-write) tool. ## /workon Select a task and seed a re-entry turn. Syntax: `/workon [id]` `/workon` has no MCP analogue. It does something only a host with a transcript can. It selects the target task — your tail, or the single most recent task when you pass none — sets the current-task pointer, records a `resume` event on the task, and injects a resume block straight into the durable transcript so the model re-enters the work with state in hand. The injected block opens with a fixed line, then the task's computed state and its open handoffs: ```text Following the cairns - resuming 2026-06-22-auth-rotation. 2026-06-22-auth-rotation [active] …computed task state… …open handoffs, newest first… ``` The command itself replies with a short confirmation, hidden from the model: ```text Working on 2026-06-22-auth-rotation. ``` When there is no task to work on — no tail and no recent task — it says so and writes nothing: ```text No task to work on. ``` An unknown id lists recent slugs and leaves the pointer untouched, the same shape `/task` uses for a miss. The transcript injection is a one-shot context patch. Headless, or when no agent context is bound, the selection and the event still happen but no block is written. Under a kli host, `/workon` is the operator counterpart to the model calling [`task_bootstrap`](/cairn/reference/tools#task-bootstrap) on re-entry. ## /where Show the resolved database path, project, and how it was resolved. Syntax: `/where` `/where` takes no tail and has no MCP analogue. It reports which store the session is talking to and how that store was chosen. The output is a fixed template: ```text Database: /…/cairn.db Resolved by: project Project: proj-1a2b3c4d5e6f (auth-service) Source: kli-dir ``` `Resolved by` is the precedence step that won — `override`, `db-path`, `data-dir`, `project`, or `global`, highest first. `Project` is the content-addressed project id and its display name. `Source` is how the project root was found: `setting`, `kli-dir`, `repo`, or `scratch`. When the session falls back to the reserved scratch project, a final line is appended: ```text Scratch: yes ``` `/where` is hidden from the model. See [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) for how the store behind that path is built and rebuilt. ## Related - [Tools](/cairn/reference/tools) — the fourteen MCP tools these commands sit over, with full parameter tables and return text. - [Serve cairn over MCP](/cairn/cli/mcp-serve) — the headless surface, where the tools travel but the slash commands do not. - [The current task pointer](/cairn/concepts/the-current-task-pointer) — what `/task` and `/workon` move, and the adopt-when-unset rule. - [TQ language](/cairn/reference/tq-language) — the query language `/tasks` runs, including why it refuses `!`-forms. - [`handoff` tool](/cairn/reference/tools#handoff) — the deterministic scaffold `/handoff` authors on top of. #### Serving over MCP `kli mcp-serve cairn` runs a kli built with the cairn extension as a Model Context Protocol server over stdio. The process exposes one extension's surface to a single client: cairn's fourteen tools, seven prompts, and seven readable resources. A client connected this way plans, observes, queries, and resumes against the same durable task graph it would reach inside kli, without adopting kli as its agent. This page documents the command, its client configuration, and the exact surface it carries. For the install paths behind the `kli` binary, see [Install cairn](/cairn/get-started/install-cairn). ## The command ```text kli mcp-serve cairn ``` The invocation is `kli mcp-serve `, where `` is the name of an extension built into the kli image — here, `cairn`. The process speaks MCP over stdio, reading requests on stdin and writing responses on stdout — the transport every MCP client uses to launch a server. On launch it prints no banner to stdout; stdout carries JSON-RPC only, every diagnostic goes to stderr, and the process blocks waiting for the first request — a clean start goes quiet rather than printing anything. It serves until the client closes the connection. | Element | Value | Notes | | --- | --- | --- | | Binary | `kli` | The same `kli` you install the extension into. See [Install cairn](/cairn/get-started/install-cairn). | | Subcommand | `mcp-serve` | Runs the named extensions as an MCP server. | | Argument | `cairn` | The extension to expose. One process serves only the extensions you name. | | Transport | stdio | Requests on stdin, responses on stdout; diagnostics on stderr. | | Scope | one client | One `mcp-serve` process serves a single client. | The working directory the client launches `kli` in selects the project whose task graph cairn opens. There is no separate database flag; the project is the directory. ## Client configuration Claude Code, Claude Desktop, and Cursor read the same `mcpServers` block. The canonical entry is: ```json { "mcpServers": { "cairn": { "command": "kli", "args": ["mcp-serve", "cairn"] } } } ``` | Field | Value | Meaning | | --- | --- | --- | | `command` | `kli` | The binary the client spawns. | | `args` | `["mcp-serve", "cairn"]` | The subcommand and the extension to expose. | The client spawns this process, lists its tools, and routes tool calls to it, picking up a newly added entry on its next restart. ## Exposed surface `mcp-serve` exposes one extension's surface and nothing else. Naming `cairn` gives the client cairn's surface alone; no other extension's tools, prompts, or resources travel on the same process. The surface is the same regardless of which client connects. ### Tools The fourteen cairn tools appear as MCP tools, each gated by the capability it declares. The full per-tool reference — parameters, return text, and semantics — is in [Tools](/cairn/reference/tools). | Capability | Tools | | --- | --- | | `:cairn/observe` | `observe` | | `:cairn/write` | `task_create`, `task_fork`, `task_link`, `task_sever`, `task_set_metadata`, `task_update_status`, `handoff`, `task_query_write` | | `:cairn/read` | `task_search`, `task_get`, `timeline`, `task_query`, `task_bootstrap` | The serve subject holds exactly the capabilities its exposed tools declare, so the `:cairn/read` / `:cairn/write` / `:cairn/observe` split applies over MCP just as it does in kli. The capability each tool declares is the gate; the tool name is not. A read-only client never reaches `task_query_write` or any other write tool. See [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities). ### Prompts Seven MCP prompts travel on the process: the six cairn-method workflow prompts plus the `cairn-method` skill, which the serve loop lists as a prompt as well as a resource. They carry the documentarian-and-TDD discipline of the cairn-method into the client's prompt picker. | Prompt | Stage of the loop | | --- | --- | | `research` | Read the ground before changing it. | | `plan` | Structure the work as a graph of phases. | | `implement` | Do one phase with the discipline it asks for. | | `validate` | Check the phase against its acceptance. | | `handoff` | Write the resumable summary. | | `resume` | Read the stones and pick the work back up. | | `cairn-method` | The whole method, expanded as a prompt. | The six workflow prompts are the named loop; see [The cairn-method](/cairn/concepts/the-cairn-method) for how they compose. The seventh, `cairn-method`, is the bundled skill surfaced in the prompt list. ### Resources Every prompt and the skill are also served as readable MCP resources — seven in all, each a markdown body the model reads straight off the prebuilt prompt or skill. The workflow prompts are advertised at `cairn://prompts/` (for example `cairn://prompts/research`), and the skill at `cairn://skills/cairn-method`; the scheme is the extension name. A client that reads the skill resource gets the method in full — the bootstrap-observe-handoff heartbeat and the discipline wrapped around it — as text the model can consult mid-task. ## What stays kli-only Three things kli provides do not travel over the tool protocol, because kli supplies them as the host rather than as MCP surface. A plain MCP client does not get them. - **Slash commands.** `/observe`, `/handoff`, `/task`, `/tasks`, `/workon`, and `/where` are registered under a kli command provider, not as MCP tools, so an MCP client cannot invoke them. See [Slash commands](/cairn/cli/slash-commands). - **Per-turn context injection.** Inside kli, the current task's live state — its status and its open handoffs — is folded into the model's view on every turn. Over MCP the client calls `task_bootstrap` itself to stay oriented. - **Compaction folding.** When kli compacts a long conversation, the observations and handoffs from the dropped span are folded into the summary so the durable markers survive the cut. An MCP client manages its own context window, and its compaction does not know about those markers. The data surface is identical either way: writes append events and fold into the same queryable projection no matter which client made them, so a graph built over MCP reads back exactly as one built inside kli. ## What a connected server reports A connected server advertises the fourteen tools listed above, among them `task_bootstrap`, `task_create`, `observe`, `task_query`, and `task_search`. `task_bootstrap` reads the live graph. In a fresh project with no current task set, it reports that there is nothing to orient on rather than failing: ```text No task to bootstrap; pass task_id or select a task first. ``` That message means the tool surface is wired and the project's graph is empty — the state a first session starts from. In a project that already has tasks, `task_bootstrap` returns state, neighbors, open handoffs, and recent observations. Either result means the server is connected and reading the right project. ## Related - [Install cairn](/cairn/get-started/install-cairn) — the declarative and runtime install ledgers behind the `kli` binary. - [Tools](/cairn/reference/tools) — the full per-tool reference for the fourteen exposed tools. - [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities) — the capability boundary that carries over MCP. - [Slash commands](/cairn/cli/slash-commands) — the kli-host steering surface that does not travel over the protocol. ## Additional pages (including unstable internal surfaces) ### Commands & Serving cairn reaches the world outside the [MCP tools](/cairn/reference/tools) through two surfaces, one for each reader: the slash commands a person types at a kli prompt, and the `kli mcp-serve cairn` server that carries the tools to any MCP client an agent runs in. ## Pages - [Slash commands](/cairn/cli/slash-commands) — the six commands cairn registers under a kli host (`/observe`, `/handoff`, `/task`, `/tasks`, `/workon`, `/where`), with syntax, model visibility, and how each relates to the MCP tools (`/workon` and `/where` have none). - [Serving over MCP](/cairn/cli/mcp-serve) — `kli mcp-serve cairn`, the stdio invocation, the `mcpServers` block clients read, and the tools, prompts, and resources it carries. The two surfaces share one task graph and read back identically; they differ only in the conveniences the kli host adds on top. Both sit behind one capability boundary; see [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities). ### Concepts Start with the one-paragraph definition in [What cairn is](/cairn/concepts/what-cairn-is); from there the section moves out to the graph and the three nouns you write, then to how plans and the current pointer move over that graph, and finally to the event log and capability boundary underneath it all. These pages are for the person who wants to understand how cairn works before trusting it with a plan; they explain, they do not instruct. When you want to run the loop yourself, go to the [Quickstart](/cairn/get-started); for exact values, go to [Reference](/cairn/reference). - [What cairn is](/cairn/concepts/what-cairn-is) — continuity for agents, the stones that name a route, and why the plan lives outside the conversation. - [The task graph](/cairn/concepts/the-task-graph) — the two stores: a typed-edge graph of slug-addressed tasks, and a full-text index of observations. - [Tasks, observations, and handoffs](/cairn/concepts/tasks-observations-handoffs) — the three nouns you write, and the events each one records. - [Plans, phases, and the frontier](/cairn/concepts/plans-phases-and-the-frontier) — why a plan is a task DAG, not a markdown file, and how the frontier finds ready work. - [The current-task pointer](/cairn/concepts/the-current-task-pointer) — the per-session slug cairn resolves slugless writes against, and what moves it. - [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) — why every write is an event in a durable log, and the database a rebuildable cache. - [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities) — how the read/write/observe boundary is drawn by capability, not by tool name. - [The cairn-method](/cairn/concepts/the-cairn-method) — the loop, the heartbeat writes, and the discipline an agent uses to leave work a next session can resume. The order is dependency order: each page names a thing the pages below it lean on, so reading top to bottom never asks you to take a term on faith. ### Quickstart This is the tutorial track for a person setting cairn up and running it through an agent: the linear path from an empty install to a plan you can resume from a clean session. cairn keeps that plan in a durable task graph outside the conversation, so a context reset reloads it instead of losing it. Read these in order. Each page builds on the one before it. - [Install cairn](/cairn/get-started/install-cairn) — add cairn to kli declaratively or at runtime, serve it to any MCP client, and confirm the tool surface is live. - [Your first cairn session](/cairn/get-started/your-first-cairn-session) — run the bootstrap-observe-handoff loop once, end to end, and learn what each returned string means. - [Plan and resume](/cairn/get-started/plan-and-resume) — fork phases, order them with `depends-on`, ask the frontier what is ready, complete a phase, and bootstrap back into the plan. When you have run the loop and want to understand why the graph behaves as it does, [Concepts](/cairn/concepts) explains the task graph, the current pointer, the event log, and the capability boundary. The full surface lives in the [Reference](/cairn/reference): the fourteen MCP tools, the TQ query language, the built-in views, and the closed edge, status, and field enums. ### Reference This section is the precise spec an agent executes against — the catalogue of cairn's wire surface. cairn provides fourteen MCP tools and one query language, and everything an agent can ask or change goes through them. Because an agent consumes it directly, the site serves these pages as Markdown and an llms.txt index as well as HTML; each tool, operator, view, and enum has a stable anchor, every returned and error string is copied from source, and the pages are written to be read out of order and deep-linked. Every call carries a capability gate in its metadata — `:cairn/observe`, `:cairn/write`, or `:cairn/read` — and this section tells you which gate each tool sits behind. [Reads, writes, and capabilities](/cairn/concepts/reads-writes-and-capabilities) explains why the boundary is drawn at the capability rather than the tool name. ## Pages - [MCP tools](/cairn/reference/tools) — the fourteen tools (one observe, eight writes, five reads), each with its parameters, capability gate, returned text, and verbatim errors. - [The TQ query language](/cairn/reference/tq-language) — TQ's sources, pipeline steps, predicates, set algebra, reflection, and the one dynamic gate that separates [`task_query`](/cairn/reference/tools#task-query) from [`task_query_write`](/cairn/reference/tools#task-query-write). - [Built-in and user-defined views](/cairn/reference/views) — the ten named views cairn ships, each with its exact TQ source text, and how a user view shadows a built-in until you `(undefine!)` it. - [Edges, statuses, and fields](/cairn/reference/edges-statuses-and-fields) — the closed vocabularies: three edge types, five statuses, eleven typed fields, and the reflective sources that read them back from the store. For the command-line surface — the six slash commands under a kli host and serving cairn over [`kli mcp-serve cairn`](/cairn/cli/mcp-serve) — see [Commands & Serving](/cairn/cli). For what the nouns mean before you call them, start at [Concepts](/cairn/concepts). # kli ## 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 | | --- | --- | | `/