On this page
I started making Niffler a while back (the old repo is still up at gokr/niffler.old) and I wrote it purely in Nim as a more classic monolithic TUI coding harness. It was a first attempt and it never reached maturity or a UX sophisticated enough to be “nice to use”.
I have been trying out a lot of harnesses, but mainly Claude Code, OpenCode and the last one was Pi. But then I got inspired by Pi and its minimalism and DeepSeek Harness and its approach to component composition. But I tried a different approach - decoupled cooperating Unix processes as components.
…so ok, let me describe what Niffler actually became. The rest of this article is written by Niffler using Deepseek v4.1 flash.
How it is built
The old version was a monolith - one process, one TUI, tools compiled in. It worked, but every new idea touched the same binary, and “extend the harness” meant editing the harness. That is the same trap every harness falls into sooner or later.
Niffler is built like a small Unix system instead. Everything is a component, and every component is its own OS process. They speak one wire protocol - JSON envelopes over NATS:
{"v": 1, "id": "1760825…-4242-1", "kind": "call", "tool": "bash",
"args": {"command": "uname -a"}, "caller": "cli"}
Four kinds - call, result, event, error - and that is the entire bus contract. Calls land on svc.<component>.call, events fan out on ev.*. The codec is 77 lines of pure std/json, mirrored 1:1 by the Nim, Go and TypeScript SDKs. There is even a demo component written in bash with no SDK at all, just to keep us honest.
What is left in core is only what cannot possibly be a component: booting the bus (core spawns its own bundled nats-server, built from source - so there is no NATS to install), supervising processes and their lifecycle, being the authority for what counts as a tool, the approval gate, and the conversation loop itself. The test I apply is: a mechanism can be a component if it can come into existence after the bus exists, and if I want the agent to be able to kill, remove or rebuild it at runtime. Everything that fails that test is core, and core stays small.
I like what falls out of it:
- One conversation is one process. The session runner is spawned per conversation and resumable from the store - killing it loses only the in-flight turn.
- Nothing outlives its harness. Every child carries a parent-death signal, so the tree dies with the top.
- The agent extends itself mid-conversation. Write source,
build,spawn, thendiscover/invokethe new tool - Nim, Go or TypeScript, the builder does not care.killstops a component,removealso deletes its record so it stays gone. - Clients are just bus clients.
niffler-tui,consoleandcliare equal peers, several can attach at once, and the TUI is itself a plugin that installs from source.
What is in the box
The lean harnesses are lean because you are expected to build the rest yourself. I wanted the rest to already be there, so most of what others leave to plugins ships as components:
- Files and repo -
read/edit/write/replace_acrosswith undo,grep/files, read-only git tools, a ranked repo map (tree-sitter tags plus personalized PageRank, Aider’s idea), and anlspseam over any stdio language server: diagnostics, go-to-definition, references, hover, workspace symbols. Adding a language is a config entry, not code. - Shell and background work -
bashruns every command as the leader of its own process group, so a timeout kills the whole tree instead of leaving orphans; long-running commands get handed to theprocessescomponent and report back to the conversation when they finish. - Subagents -
agentruns children in the background, you can steer them mid-flight, and when one settles it wakes your conversation with the result. Their budgets are hard-wired and their gated calls route to me. - Programmable tool calls -
fabriccompiles a Nim program into a private process that can call tools itself, with budgets; only itsfinish(value)enters the conversation. That is where mechanical, known-shape work belongs. - An advisory peer -
expertcan follow a session and hand me a second opinion as a turn-bound suggestion. It never touches the tools. - Context that survives - durable compaction plus a
recallcomponent that can grep the whole canonical history, including everything that was trimmed away. - Ecosystem - skills (SKILL.md, four-source search order, installable from git), plugins, and external MCP servers that show up as ordinary catalog tools.
- Models - any OpenAI-compatible endpoint, a store-backed provider registry, and subscription OAuth for ChatGPT and Claude if that is what you pay for.
- And the plumbing I got tired of missing -
observe/logfileto watch the bus live, hooks on bus events, and a store with two engines behind one contract (SQLite by default, TiDB if you want a shared cluster).
Two details under all of it shape every single turn. The tool set in front of the model is deliberately tiny and frozen: eight direct tools (discover, invoke, bash, read, write, edit, replace_across, grep) plus a very small base prompt. Everything else is one discover/invoke away, entering as history instead of prompt bloat, so the request prefix stays byte-stable and the provider cache stays hot - the TUI prints the cache hit rate on every turn.
And approvals are real: a tool can be marked as gated, the call reaches me as a y/N, subagents included. With no human reachable the call is denied, never silently allowed.
Why I think it is the right shape
My two USPs are really the same property seen from two sides.
- No language is privileged. Core is Nim, the store and model adapters are Go, the TUI is Go, one demo component is bash with no SDK. Shared components have no per-language branches, because language support is data - the LSP registry, the skills, the model catalog. So “I want a tool for this” has the same answer no matter what language the tool is in, and Nim is a first-class citizen in my own harness instead of a guest.
- Everything above core is replaceable. Not configurable - replaceable. The store has two engines behind one contract (swap them at boot). The
systempromptcomponent is the conversation constitution, so replacing it replaces the constitution. The retirable layer is exactly the layer I want the agent allowed to touch: it can write a component, spawn it, and take it out again without a restart.
What I notice in daily use is not the architecture, it is that when I want something different I do not file a feature request with myself in a monolith. I write a component. That is the whole point of caring about the boundary.
How I use it
Since a few weeks back Niffler is the only harness I start. OpenCode and Pi are still installed, I just never reach for them.
My normal setup is 4-5 niffler-tui in tabs in ghostty, all attached to the same local harness. That works because a tab is a client, not a harness: no per-tab process tree, no per-tab model config, no per-tab sessions to babysit - just another view onto the same instance. One tab is the actual task, one is a subagent wrangling something long, one is scratch work I do not want polluting the main conversation. They do not fight, because they are separate conversations with separate workspaces and separate processes.
A few things I use constantly: pressing Enter while a turn is running steers it instead of starting a new one (very useful when the agent is heading somewhere silly), Ctrl+O shows the worker roster - the background processes and subagents that conversation owns - and the cache hit rate sits on screen so I can see when I have done something that poisons the prefix. The TUI also feels like a client, not a product: this morning’s build is whatever I have in var/bin.
What I am working on now
Three things, all still in flight.
Remote Nifflers. The bus is already a remote control plane, so the transport is deliberately boring: an SSH tunnel to the instance’s bus. What is actually missing is identity, lifecycle and a client that can hold more than one bus. So: instance identity (NIF_NAME plus a persisted instance id, exposed in the catalog), a scoped systemd user service that refuses to adopt a PID or overwrite a unit it did not write (scripts/server.sh), provisioning over ordinary SSH or Multipass where profiles are verification receipts and no credentials are ever copied, a heartbeat component that can wake an agent nobody is driving, personas resolved once into the frozen prompt at birth, and cross-instance delegation where a remote child is just an ordinary conversation on the peer that I can open and take over. On the client side, a /niffler switcher in the TUI, so switching instances feels like switching provider/model. This is branch work with tests per step, and I will write about it properly when it has survived real use.
Sandboxing. I am not going to say “the agent is sandboxed”, because that would be a lie until it is containment and not just damage control. The first release I am aiming at is opt-in shell write protection: a small launcher where the workspace is writable, the host root is read-only, /tmp is private, the network is isolated, capabilities are dropped and the child environment is fixed so API keys cannot leak into a command. It reports what actually happened - the “applied” verdict is earned from the real launch instead of a parallel probe - and a backend that cannot enforce something (a secret mask under Landlock, say) refuses rather than pretending. Fail closed, or say plainly that it is off.
Jev. A small, fast decision model as an advisory layer. jev_recommend, jev_suggest and jev_decide let the harness ask typed questions - is this true, which of these, what score - in tens of milliseconds instead of spending a full LLM turn on it. The recommended use is discovery: “which of these 24 candidate tools fits this task?” It is strictly advisory (it grants no permission and invokes nothing), the endpoint must be loopback because the inputs carry repo context, and the runtime (Von) is an opt-in install of a few GB that is never part of a normal setup. It also ships a shadow experiment that judges every turn start in the background and stores the answers, so I can find out whether it actually helps before I let it decide anything at all.
Try it
If any of this sounds like the harness you wanted, go grab it:
curl -fsSL https://raw.githubusercontent.com/gokr/niffler/main/scripts/bootstrap.sh | bash
…or by hand: clone it, make setup, make build, copy .env.example to .env and add a model key, then make install-tui and run niffler-tui. You need Nim 2.2.12+ and Go; Node is optional and the installer asks first. It is built from source - there are no prebuilt binaries - and the bus is bundled, so there is no NATS to install. v0.4.0 is the first stable and complete release, and the TUI is on 0.6.1.
Everything lives in gokr/niffler: the manual, the wire protocol, the architecture and the full feature inventory. The terminal client is its own repo and plugin, gokr/niffler-tui, and there is a website at niffler.flatout.works with the benchmark numbers if you like that sort of thing - measured against Pi, opencode and Claude Code on the same tasks and models, because “efficient” should be a number and not a vibe.
If you try it and something breaks, or you build a component you think belongs in the box - tell me. Discord is fastest, issues work too, and both are linked from the repo. Niffler is small by default and builds the rest, so a component you write is not a patch to my harness - it is a component.