kmet - Coding Agent Written in Clojure
Table of Contents
1. Why
My previous post about rab ended with a promise: "maybe next time I'll start from scratch with all the lessons learned."
kmet is that next time. It breaks exactly one rule from that post: the language.
rab rejected Clojure on four grounds, in a paragraph I still stand behind as reasoning:
TUI libraries in Clojure are virtually non-existent. JVM startup times kill the instant-feedback loop a TUI tool needs. Memory footprint is an order of magnitude higher. And most critically: even the best LLMs generate poor Clojure code.
kmet is a terminal-first coding agent written entirely in Clojure. Those four claims were accurate when I wrote them. Two of them were about the runtime rather than the language, and one of them stopped being true. Getting from that paragraph to a working daily driver is most of this post.
The other reason is unchanged from rab: I wanted to understand coding agents from the inside out, and building one is the only way I know to do that.
2. Goal
A coding agent in the spirit of pi: a terminal interface, my own tool loop, my own provider layer, no IDE dependency and no browser. Streaming conversations, a multi-line editor, a set of primitive tools, sessions on disk, extensions loaded at runtime.
The addition since rab is architectural. rab's problem was never the agent loop, it was that every feature touched three subsystems. kmet is organised around the opposite premise: everything is a plugin. The loop, the tools, sessions, model access and the run modes are rows in a composition, not code in an entry point. An extension mounts beside the core instead of patching it. That is not my idea, and it is credited below.
kmet runs from one source tree on three hosts: Babashka as the primary runtime, native Jolt for self-contained binaries, and the JVM. Same namespaces, same behaviour, different startup cost.
3. Name
kmet (Cyrillic кмет) is an old Slavic word with two opposite senses, and Serbian keeps both. A kmet is the village elder, the first man of a community and its judge. A kmet is also the bondsman bound to the land he works. Other languages keep one half: Bulgarian кмет is mayor, Slovene kmet is farmer.
Both senses fit a coding agent. It decides what to do next and it does the work it is told to do, in a codebase that belongs to someone else.
The kernel is called srce (срце), Serbian for heart. Cordis is the Latin word for the same thing, which is where the name comes from. srce lives in its own module and carries its own documentation.
4. Credits: pi, DeepSeek Harness, Cordis
Three projects deserve naming.
pi is the functionality. kmet is a close port of pi's stable interactive core: the component model, the TUI, the agent loop, the tool set, the provider and auth subsystem, sessions, compaction, skills, prompt templates, themes, and the extension API. I use pi daily and treat its behaviour as the specification. Where kmet differs, the difference is deliberate and tracked in a gap document.
DeepSeek Harness (dsh) is the architecture. One context tree, services found by key, dependencies declared, effects that unwind, extensions mounting beside the core. Five of its decisions are folded in directly: an agent handle kept separate from the append-only session log, an agent loop that provides a service so the run modes wait for it instead of starting it, a --dump-config that composes the row tree with the same patch algorithm boot uses, a per-agent scoped setup composed before the agent is published, and the profile/bundle/patch composition model. I declined three: web-first, npm as the plugin boundary, and live recomposition of a running composition.
Cordis is the kernel. It is a plugin meta-framework in TypeScript, and dsh is built on it. Its five ideas are: a plugin is a service with optional injection and an apply function; a context is a repository of services found by key; dependencies are declared, so load order stops mattering; events are typed and dispatched; registrations are reversible effects. kmet's kernel reduces those five to four Clojure namespaces and about nine hundred lines, with nothing vendored. A plugin is a map with :inject and :apply rather than a Service subclass, there are six synchronous event modes rather than awaiting ones, and there is no logger subsystem, interception chain or drain thread.
| Cordis idea | kmet's kernel |
|---|---|
plugin as Service subclass |
a validated map with :inject and :apply |
| services by key on a context | provide! / get by keyword over a ctx tree, isolate for realms |
inject drives activation |
required and :optional deps, a refresh pass, so boot order does not matter |
| typed events, five dispatch modes | one listener table, six modes, all synchronous |
| registrations are effects | a fiber's effect list, LIFO, owning every provide! and listener |
Everything in kmet is either ported from those three projects or is a consequence of porting them to Clojure.
5. How, or the four objections
5.1. "TUI libraries in Clojure are virtually non-existent"
They still are. So I wrote the stack, in three layers:
- reakt - reactivity over plain atoms. A reaction discovers its dependencies when it first runs, so there is no subscription graph to maintain by hand and no scheduler to configure. Invalidation schedules the next frame.
- hikap - a hiccup component engine that is toolkit-agnostic. Records are the terminal's DOM elements, plain functions are the composition layer above them, and the reconciler compiles trees to records and diffs them by key. Nothing in it knows what a terminal is.
- tui - the terminal backend: the tag table, width-aware rendering, input decoding, overlays, the editor dock, and the frame loop. Every record caches its rendered lines per width, and a frame emits only the diff against the previous one.
A component in that stack is a vector, not a class:
[:box {:padding-x 1}
[:text "hi"]
(when-let [s @status] [:status-line s]) ; nil is skipped
(map (fn [m] ^{:key (:id m)} [:text (:content m)]) msgs)] ; keyed children
The layer boundary matters. hikap has no terminal in it, so a component tree can be rendered headless in a test as data, and another toolkit could supply a different backend. That is the lesson I paid for in rab, when I ripped out Ratatui and ported pi-tui: I had bought an application framework when I wanted a renderer.
5.2. "JVM startup times kill the instant-feedback loop"
Startup is a property of the host, not the language, and Clojure has three of them.
Babashka starts in milliseconds and needs no JVM at all. Jolt, a dialect with its own native runtime, compiles to a binary. The JVM is the slow one, and it is the third option rather than the only one. The same source tree produces all three. The only portable-code rule I keep is that reader conditionals name the host (:jolt, :bb) and never :clj, because :clj matches both.
Three hosts is work: host-specific tasks, and a rule that both lint views stay valid. It turns a disqualifying property into an option.
5.3. "Memory footprint is an order of magnitude higher"
Also a host property, and the inline TUI helps. kmet does not own a viewport. The transcript lives in the terminal's own scrollback, so the tool renders at natural height and lets the terminal keep the history, instead of maintaining a full-screen buffer that duplicates what an alt-screen app already has.
5.4. "Even the best LLMs generate poor Clojure code"
This one was true, and it is the one that changed most. Two things fixed it, and I want to separate them, because I claimed the first for years and it was the smaller half.
Fix the tools. An LLM writing Clojure fails on delimiters and on macro shape, not on ideas. So the agent gets structure-aware tools. clojure_edit finds a definition by type and name and replaces it as a form, rejecting unbalanced content before it reaches the file. clojure_edit_replace_sexp changes an expression without touching its neighbours, and a paren-repair tool fixes a file that is already broken. cljfmt runs on exactly the forms an edit changed, so untouched code keeps its bytes and a diff shows only the edit. A tree-sitter extension validates syntax and blocks a broken write. The clojure extension also gives the agent an nREPL client, so it can evaluate what it wrote instead of guessing.
Those tools started as ports and ended up as improvements. The editing tools come from clojure-mcp and the nREPL client follows clojure-mcp-light; both were the inspiration, and neither is what ships. What ships is a rebuild around kmet's own edit path: unbalanced content is rejected before the write, formatting touches only the forms the edit changed so untouched code keeps its bytes, defmethod dispatch values are matched, and the result renders through kmet's normal edit renderers and numbered diff, so a Clojure edit looks like every other edit the agent makes.
The upstream project has since moved the other way. clojure-mcp now recommends its REPL profile and keeps the editors in maintenance mode, on the grounds that frontier models edit Clojure reliably without them. That is a reasonable call for a tool that lives beside a live REPL. In kmet the editors stayed load-bearing, because the agent has no REPL session unless one is running for it, and every file change goes through the same edit path.
The effect is not marginal. Without a form-aware editor the model has to produce balanced text by counting characters, and that is where most of the bad output came from.
An edit is a call on a form, not a text replacement (written here as EDN; the wire is JSON):
{:file_path "src/my_app/core.clj"
:form_type "defn"
:form_identifier "my-function"
:operation "replace"
:content "(defn my-function [x y] (+ x y))"}
The tool finds the definition, checks the content is balanced, replaces the form, and formats only what it changed.
The model got better. The endpoint I use daily now is a recent DeepSeek 4 Flash based model, and it writes Clojure I routinely accept: idiomatic let and threading-macro usage, sane destructuring, no invented macros, no (defn foo [& args] (apply ...)) for everything. The Clojure it produces is not the Clojure the models produced a year ago. I was wrong about that being permanent. It was a phase.
So Clojure was a bad agent target because the tooling for editing it was text-shaped, and a mediocre runtime target because the default host was slow. Neither of those is a property of the language.
6. What went well
The kernel. srce is four namespaces, one public one (kmet.srce.core), no third-party dependencies, and no clock, queue or protocols. A plugin is a map. It declares what it needs, it becomes active when those services exist, and unmounting unwinds everything it registered in LIFO order. The consequence that matters daily: mounting an extension, a profile row and a run mode can happen in any order, because nothing asserts boot sequence, and /reload is one call rather than a convention every subsystem has to remember.
One namespace is the ABI. An extension is a namespace that requires kmet.extension and defines a plugin var. It gets a ctx, a map of capabilities, and its own SCI context with its own deps.edn, so an extension can depend on a library kmet does not have, and reloading it does not restart the app. Registration is a function call on the api map, not a mutation of anything global. There is no compatibility promise before 1.0 and the in-repo extensions move with the contract, which keeps the contract small enough to read in one sitting.
(ns my.hello-ext
(:require [kmet.extension :as ext]
[kmet.srce.core :as srce]))
(srce/defplugin plugin
{:name :ext/hello
:apply (fn [_ctx api]
(ext/register-command! api
{:name "hello"
:description "Say hello"
:handler (fn [_ args] (str "Hello, " args "!"))})
nil)})
That is the whole extension. The nil return matters: a body that starts background work answers nil, not the future it started, because every registration the body made is already an effect on its fiber and unmounting unwinds them in order.
run_code. The tool that does not exist in rab. It runs model-written Clojure in a per-call SCI sandbox with the agent's own tools bridged in as functions, so a script can call (read ...), shell out, or ask for a tool's schema, and then emit only a digest. When a task is "scan two hundred files and tell me which ones", the model writes fifteen lines of Clojure and prints three values instead of reading forty files into its own context. Facts are computed in the sandbox, and only the result crosses the wire.
;; count what the tree is made of, emit only the digest
(->> (fs/glob "." "**/*.clj")
(map #(-> % fs/file-name str))
frequencies
(sort-by val >)
(take 5)
(emit))
Context economy as a design constraint. The mcp adapter and the lsp adapter are each one tool, a proxy of roughly two hundred tokens that discovers and describes what is behind it. Hundreds of tool definitions, which is what a naive MCP client ships, is a permanent tax on every request. One tool for a family of operations beats ten thin ones.
Babashka first, dialects after. kmet targets Babashka as its primary runtime, and every commit since the first one was written against it. Babashka's author built it as Clojure without the JVM: millisecond startup, a large part of the language and the ecosystem already there. A bb-first tree has therefore already answered the questions a port asks. Does this touch a class, does it need a build step, does anything assume the JVM. The discipline shows up as plumbing rather than architecture (babashka.fs instead of java.nio, babashka.process instead of ProcessBuilder), and it is the reason adding two more hosts was work rather than a redesign.
Jolt is a dialect, and the port went well. Jolt is not a faster way to run the same Clojure. It is a new Clojure dialect by Yogthos with its own native runtime, so porting to it is a conversation rather than a download. Yogthos is responsive, the issues I filed mostly came back fixed upstream, and the fixes were improvements to the dialect rather than one-off patches for me. A user who runs a young host hard and reports what breaks helps that host, and a host that answers quickly helps the user. Both projects moved forward.
Three hosts. Babashka for development and startup, Jolt for self-contained native binaries, the JVM where a real classpath is required: one source tree, three views of the same namespaces.
Dogfooding from the start. kmet has been my daily driver for most of its life. Find a missing feature, add it as an extension, reload, use it in the same session. That loop is as productive as it was in rab, and I debug kmet by running kmet on kmet.
7. Where kmet departs from pi
kmet started as a port and is not one any more. The alignment document records the gaps against pi 1.3.0, but closing all of them is not the goal. A new pi feature now falls into one of three groups: port it, decline it, or leave it out because it does not fit what kmet is.
7.1. Declined
- Fullscreen TUI mode, which pi 1.3 made its default. kmet renders into the terminal's native scrollback and that is not going to change. The cost is the history the terminal already keeps and the keys the user already knows.
- Mermaid diagram rendering. Postponed with no plan behind it.
- npm as a package source. kmet installs git repositories instead, including a
//subdirinside a repository so several packages share one checkout. npm is not the boundary I want for extensions. - A web front end. The TUI is the front end. A second toolkit is where a web UI would plug in, and it is not planned.
- Live recomposition of a running composition. Enabling an extension restarts the process today. Doing better needs a diff of mounted fibers that no dependent re-resolves, which is a kernel change rather than a flag.
- pi's built-in search tools. pi ships
grep,findandlsas built-ins. kmet keeps read, write, edit, bash andrun_codebuilt in and ships search as opt-in extensions. Same features, smaller default surface.
7.2. kmet-only
- Quiet tool display: one dimmed title line per tool, no box and no hint, cycled with
Ctrl+Oand persisted in settings. pi'stoolOutputExpandedhas two modes; this is the third, and it is the mode I use. - Streaming thinking display:
:stream-lastshows only the tail of the live thinking block while the model reasons, then collapses to the hidden label. pi has a boolean hide-thinking switch. kmet has three display modes and cycles them withCtrl+T. - Repeat-loop guard: a circuit breaker that detects identical tool calls, or repeated reasoning, over a sliding window and settles the run instead of letting it spin. There is no pi counterpart.
- Server profile:
--profile serverserves the RPC protocol over TCP loopback or a unix socket, and each connection gets its own agent, session and working directory. pi removed its client and server packages in 1.3.0. - A Tk backend: the same component tree rendered into Tk widgets instead of a terminal, shipped as a sample. It exists to prove that hikap has no terminal in it. It is not my daily UI, and the point is that it did not need a second renderer.
The line I draw: a pi feature gets ported when it belongs to the agent loop, the tools, the model layer or the extension API. It gets declined when it belongs to a different product: a fullscreen app, a browser, a different package registry, or a live reconfiguration model the kernel was not built for.
8. What I didn't like
The frame path, and specifically the cost of the inline TUI.
I render into the terminal's native scrollback instead of owning an alt-screen viewport, because the transcript belongs there: the user browses history with the terminal's own keys while output keeps streaming below. That decision is correct and expensive. The terminal has no addressable scrollback, so any change that lands above the window cannot be repainted. kmet clamps such a diff to the window top, records that the scrollback is now stale, and rebuilds it with one clearing full redraw at a boundary where the user is not mid-stream. Getting that wrong means a viewport that jumps to the top in the middle of a response, which is what a naive full redraw does on some terminals.
The rest of the renderer exists to keep that rebuild rare. Records cache their lines per width, a tool block's body is constant for its whole life so a pending-to-success transition cannot touch history, and frames emit the minimal diff. It works, and it is more machinery than any other part of the UI, all of it in service of a property the user never sees. The hard part of this project is not the agent.
The other thing I did not enjoy is inherited from a young host. Jolt is fast-moving and incomplete in ways that are invisible until you hit them: a missing JDK member, a constructor arity, a class that the runtime models but does not fully supply. The library in jolt/ exists to declare those gaps. At the moment it is empty by design because the runtime caught up, but I have spent evenings on tickets that a mature host would never have raised. What made those evenings worthwhile is the other half of the trade: almost every ticket I filed came back fixed, #1031 excepted, and each fix improved the dialect for everyone rather than unblocking me alone. I still carry the scaffolding for the next gap.
9. Cracks in the foundation
rab's cracks were architectural: too many moving parts, and the fix was to stop owning the base layer and build on yoagent.
kmet's first version had the mirror-image problem: a process-global registry node, sixteen mutable process globals, and a per-extension capability map that had to be updated whenever a capability was added. It worked, in the way that a global works, until two mounting orders disagreed about who owned a name. Nothing about that is specific to Clojure. It is what you get when a plugin system grows a string of globals one special case at a time.
The fix was the kernel, and it was not a refactor. Services became keys on a context tree, dependencies became declarations, and every registration became an effect owned by the fiber that made it. A mechanism belongs in the kernel only if it passes all three tests: the application needs it as a service, it is what separates a kernel from a service locator, and it could not be added later without changing every caller. Everything else is deferred rather than dropped, with the seam that makes re-adding it additive recorded next to its Cordis ancestor. That record is why I could cut a full port of ten namespaces down to four without losing track of what I removed.
The change in one comparison:
;; before: sixteen process globals, one per capability
(defonce tools (atom {}))
(defonce events (atom []))
(defonce renderers (atom {}))
;; after: the ctx holds the name, the fiber owns the disposal
(srce/provide! ctx :tools table)
What is still cracked: the composition is edited by humans. There is a --dump-config that composes the row tree exactly as boot would, and a generated catalog that the build verifies, but a model-facing tool that edits composition and reports applied versus restart-required does not exist yet. Live recomposition of a running composition, which dsh has, is declined until it can be scoped to leaf rows whose disposal no dependent re-resolves. Until then, enabling an extension means editing a patch file and restarting.
The patch file is a composition, and it is three lines:
;; ~/.kmet/agent/patch.edn
{:rows [{:id :ext/clojure :plugin :ext/clojure}]}
10. Where it's still rough
The 90% problem did not go away. It moved.
The alignment document is a six-hundred-line list of what pi has and kmet does not, refreshed against pi 1.3.0 and kept current. Reading it is the fastest way to know what is missing. Most of it is small: a slash command, a setting, a rendering detail. Some of it is not.
Provider coverage is aligned on paper (forty-odd model catalogs, OAuth, custom models, the auth flows) and tested on the ones I use. Anthropic, Gemini, Bedrock, the local servers: I have not exercised them daily, so they are as good as my reading of their documentation. That is the same standard of quality rab had for its providers.
And there is still one user. kmet has releases, a test suite, a lint gate with zero tolerance for warnings and a formatter that runs on every change, so it is not a prototype. But a bug nobody has hit is a bug that is still there, and the edge cases (a terminal resize mid-stream on Windows, a SIGPIPE, an unusual TTY) are found by users, not by me.
11. What's to come
Three tracks have a foundation and no finished product.
11.1. Persistent eval, after nodecode
nodecode is an SBCL agent whose one tool evaluates Lisp in a live image and keeps every definition it makes. The agent grows a library instead of re-typing one, and an optional folder can teach it a new tool or skill just by being present.
kmet's run_code is the opposite by design: a fresh SCI context per call, discarded on return. That is right for compute-and-report work, and it is why nothing the model writes there can be reused. A helper for this repository's log format gets re-pasted on every turn, and the only way to keep one is to write it to a file and load it through a sandbox that refuses everything outside a fixed namespace surface.
The plan is an eval tool beside run_code: one SCI context per agent rather than per call, so a definition survives the call that made it. Definitions can be kept on disk under an explicit scope (project or global), loaded at the next start, or held in memory for the agent only. A small vocabulary manages them: eval/kept, eval/sources, eval/patch, eval/forget. Extensions contribute definitions the way they contribute tools, through one registry tag and one unload sweep, with no new ABI shape. The environment belongs to the agent's child ctx, so a subagent gets its own and never the parent's, and persistence is opt-in per call, so nothing is written into a repository unless the call asks for it.
The decisions are closed and the implementation has not started. This is the track I want next, because it changes the shape of the work: a repeated helper becomes a function the model calls.
11.2. RPC and server modes: transports without clients
--mode rpc speaks EDN over stdin and stdout, and --profile server serves the same protocol over TCP loopback or a unix socket with one agent per connection. The record vocabulary, the handshake, the token and the per-wire queue are in place, and both run today.
What is missing is on the other side. No editor plugin ships with kmet, and there is no session browser and no remote story beyond "one host, one principal". pi removed its client and server packages in 1.3.0, so there is nothing to port. Anyone who wants to embed kmet in an editor has the protocol and has to write the client. That is a real project, and it is not this one yet.
11.3. The Tk GUI is a sample
kmet has a second backend: kmet.tk renders the same component tree into Tk widgets, through an FFI binding to a real Tcl interpreter, on Babashka, Jolt and the JVM. It ships with one small sample application.
It exists to prove the layer boundary. hikap has no terminal in it, so a second toolkit cost a backend rather than a second renderer. What it does not have is a product: no session browser, no review flow, no packaging. A GUI is future work with no schedule, because I do not need a second UI. The backend is there when someone does.
Everything else that is planned or declined is written down in the alignment document and the design record.
12. Where I ended up
rab is the post where I went back to pi. kmet is the one where I do not.
I use kmet every day. That is a different ending for the same reason: the language stopped being the obstacle. When something is wrong, the fix is in Clojure, in a codebase I can read end to end, at whichever layer the bug actually lives (editor, renderer, tool, kernel, loader). With rab I was always choosing between three subsystems I did not fully own. With kmet the whole stack is one artefact I can hold in my head, and the extension boundary means a new behaviour is a twenty-line file rather than a patch in a privileged core.
The gaps are still there and still listed. But "restart-required" is a better place to stand than "no users, no motivation".
13. What I learned
Adopt the reference architecture, not just the reference features. rab gave me the agent loop and nothing else, so every feature I added had to invent its own place. Starting from a plugin model instead (services by name, dependencies declared, effects that unwind) meant the second and third features were cheap, and /reload was possible without an architecture change.
A plugin system is small until you add a global. The kernel is nine hundred lines because it refuses to do five things. Every mechanism it declined has a recorded seam, so re-adding one is a lookup rather than a redesign. The temptation to add "just one" convenience is the erosion the deferred table exists to resist.
Start with the smallest host. A tree that runs on Babashka runs everywhere. The reverse is not true. Picking the leanest dialect first turns portability from a rewrite into a negotiation, and it makes the reader conditionals a fact about the code rather than a flag you add later. Two more hosts cost me plumbing (babashka.fs instead of java.nio, :bb and :jolt branches, generic helpers in a shared libs layer) and not a second architecture.
Give the model better tools before blaming the model. A form-aware editor, a format-on-changed-forms pass, and a sandboxed script runner did more for Clojure generation quality than any prompt I have written. Then the models got better anyway, and I had to admit the second half.
The renderer is the hard part. The scrollback machinery is the most complex code in the project and it adds no features. That is where the engineering is, and it is the part I would have got wrong if I had copied a framework instead of writing one I could debug.
kmet lives at https://github.com/kmetia/kmet-agent. It is under active development and it is ready to use: it runs on Linux, macOS, Windows, WSL and Termux, it has releases, and it is the tool I use for my own work every day.
If you write Clojure, use it on a real project. Bug reports, feedback and pull requests are welcome, and the issue tracker is the fastest way to get something looked at.