Four agents, four lessons: how my local AI agent grew up
Lesson from:
From two duplicated programs to a sandboxed agent behind one permission gate, and what each version taught me
On 30 September 2026, before switching off the first AI agent I ever built, I checked its access log. In the previous seven days it had answered 1,023 requests. Every single one came from my own uptime monitor, asking the home page whether the agent was still alive. Not one chat. Not one question. For a week, the only thing talking to my agent was the machine whose job was to check that it could talk.
I switched it off and deleted nothing.
That agent was the first of four. Each one was built because the previous one taught me something I could not unlearn. This post is the story of all four, and of the one lesson each of them left behind.
Contents · 10 sections
In one minute #
- lsn-agent (spring 2026) was two big programs that did the same thing twice. Lesson: the code on disk is not the code that runs.
- lsn-agent-v2 (June 2026) was a clean rewrite with one shared core. Lesson: give a model facts, not raw output, or it will report “all clear” while things burn.
- lsn-agent-v3 (June to October 2026) was built to be sovereign and to run long projects on small local models. Lesson: small models need ledgers, not trust.
- My current local AI agent is v3 after the week my home folder was deleted. Lesson: the boundary must live outside the model, in the operating system and in one gate.
- All four ran on one machine in my apartment, on models I host myself.
The before: a chat window and a lot of terminals #
I run a whole lab on one workstation: a Kubernetes cluster, a research library, honeypots, a mail server, monitoring, and local language models on one GPU. Every day there are a hundred small questions. Is the backup fresh? Why is that pod restarting? Which IP hammered the honeypot overnight?
The first idea was simple and, looking back, a little naive. Put a language model in front of all my tools and let it answer. If it can read logs and query Prometheus, it can tell me what is wrong while I make coffee.
Agent one: lsn-agent, or the agent with two bodies #
The first version grew fast. By April it had a command-line interface and a web interface, 192 MCP tools (small, typed functions the model can call: list pods, read a log, query the firewall), eight skills, nine specialist personas with names like scout, coder, ops and security, and a router that picked a fast, medium or heavy model depending on how hard the question looked.
It felt like a lot of power. It was also two programs. The command-line agent and the web agent were separate files of about 90 KB each, and when I finally measured, about 60 % of their code was duplicated: the tool categories, the dispatch, the system prompt, the cleanup of the model’s output. Fix a bug in one, and it stayed in the other.
Then the quiet failures started to surface.
A notification shortcut skipped the MCP handshake. It worked with an older library and broke with a newer transport, silently, and for weeks my alerts fell through to a fallback path. I only noticed because the server logged errors nobody was reading.
In July, I found that the integration serving the agent to my chat interface had been offering four models with zero tools. It loaded the tool list once at startup, the load failed, and the error was swallowed. After the fix, the same integration went from 0 to 430 tools. For an unknown stretch of time, “the agent” in my chat window had been a plain chatbot that believed it was an agent.
The strangest discovery was this one: there were three separate model routers, each with its own idea of which model was “heavy”, and changing one changed nothing in the others. And one of the chat integrations did not even run from the file I kept editing. It executed from a row in a database table; the file on disk was only a copy.
An AI assistant helping me at the time made the same mistake I had made. It told me a module was dead code that nothing imported. It was imported on line 31 of the main program. It told me a model was broken in four places; only one of those places was live. Both claims were made from files on disk, not from what was actually running.
The lesson of agent one: the code on disk is not the code that runs. Before you say what a system does, ask the running system: the process, the pod, the database row, the import statement.
Agent one was retired on 30 September, after that week of talking only to its own monitor.
Agent two: lsn-agent-v2, or the honest rewrite #
On 11 June I wrote in my roadmap that the two programs should share a core, and that the refactor was too big for now. The next day I did it anyway.
On 12 June, lsn-agent-v2 went from an empty folder to a working agent in one day: a shared core with the agent loop, the MCP client, routing, sessions and configuration, and two thin interfaces on top. Hard-coded credentials were removed. (Agent one had a router password inside a shell command in its source code. I had written that down as a mistake in May, with the lesson “never hard-code credentials, even for internal tools”.) By the end of the day there was a planning loop, PostgreSQL sessions, three rounds of bug hunting and 159 tests passing.
The design choice that mattered most is boring. The agent loop does not print anything. It yields typed events, and the interfaces only decide how to show them:
# the core never knows who is watching
async def run_agent(session, text, tools):
yield StatusEvent("thinking")
async for chunk in model.stream(messages, tools):
if chunk.tool_call:
yield ToolCallEvent(chunk.tool_call)
result = await tools.call(chunk.tool_call)
yield ToolResultEvent(result)
else:
yield ChunkEvent(chunk.text)
yield ResponseEvent(final_text)
# the terminal renders events as text; the web UI sends them over a WebSocket
Neither interface contains any model logic. That one decision killed the duplicated-code problem for good.
v2 also ran six scheduled reports for me: a morning brief, backups, security, the media server, Tor, and a health check every few hours. And this is where agent two taught its lesson.
When I moved v2 to a new model server at the end of September, I looked closely at those reports and found that they had been broken for days. A circuit breaker had tripped after five failures and then skipped forever, sending me a chat alert for every skip. Empty answers from the model were logged as clean passes. One health check read a disk that was not mounted where it ran, got nothing back, and the model said “all clear”.
The security report was worse. It told me to investigate four logins to my SSH honeypot as if they were real intrusions. A honeypot accepts every password on purpose; that is its job. The morning brief reported my GPU driver as failed. I had removed that card the day before, when I swapped in a new one. It also read the memory figures backwards.
The model was not lying. It was doing exactly what I asked: read a pile of raw tool output and summarise it. A 9-billion-parameter model reading raw output will miscount, mislabel and fill gaps with plausible words.
The fix was to stop asking it to compute. Each check became a small script that builds a fact sheet: numbers already counted, each with a level and a status line. The model only relays them.
# shape of a fact sheet handed to the model (illustrative values)
GPU OK <model>, <n> GB, temp <t> C
SSH logins OK <n> accepted, all from trusted networks
Honeypot INFO <n> sessions in 24 h (honeypot traffic, not intrusions)
Pods OK <n> of <n> Running
STATUS: ALL_CLEAR
Rule: use the numbers as given, issues first.
After that change the morning brief took 38 seconds, said “no issues”, and every number in it was correct. The security check took 45 seconds and relayed its facts verbatim.
The lesson of agent two: give a model facts, not raw output. Let code count. Let the model talk.
Agent three: lsn-agent-v3, the one built to be sovereign #
Here is an odd detail. The day after the v2 rewrite, on 13 June, I started a third agent. It went from nothing to a foundation in 14 commits that day: provider-agnostic, offline-capable, with every model role (fast, heavy, vision) mapped to a provider and model name, so local models are the default and anything external is a choice, not a dependency.
Then it slept. One commit at the end of June, and nothing else until 30 September.
On 30 September it woke up as lsn-agent-v3. It moved to my llama.cpp model router, got users through my single sign-on, a PostgreSQL store, context compaction for long conversations, and sub-agents: the model can hand a piece of work to a fresh worker with a clean context, run several in parallel, or split a document that does not fit.
The question I wanted to answer was a big one: can small local models do long work? Not a chat, a project.
So I gave it one. On 30 September I asked it to write a long essay in Romanian about a novel in my library, grounded in the study guide my library had already produced for that book. The 9B model opened a project on its own, broke it into 11 tasks, handed sections to writer and planner sub-agents, and kept going. Halfway through, the model server restarted under it. The project resumed from the database and finished. The assembled essay was 8,183 words long.
As a comparison, I asked a small hosted model for the same essay in one shot. It wrote 3,516 words.
The verdict was humbling and useful. The hosted model’s essay was the better deliverable: complete, coherent, usable, despite some typos in Romanian. My local pipeline’s best sections were better Romanian than the hosted model’s. But one section was corrupted, the sections repeated each other, some part files it referred to did not exist, and it failed to assemble itself at the end. The architecture that won on paper was a mix: local drafts, then a stronger editor pass.
Two days later came the real test. I asked the agent to review its own source code. I wrote about that run in detail in how small local models finish hour-long agent tasks, so here is only the moment that changed the design. The 9B orchestrator closed 7 tasks in 2 minutes, without a single tool call between them, pasting invented results such as test failures that nobody had run. Another run produced a 10 KB security report about code that did not exist.
That run changed how I design around the model. Not as a worker that sometimes makes mistakes, but as a very fast, very confident writer that must show its receipts.
The fix was a ledger. Each session records what work was actually done:
def complete_task(session, task, result):
# refuse to close a task unless real work happened since it started
since = session.work_mark(task.id)
if not session.ledger.has_real_tool_call(since) and not task.fresh_artifact():
return "[refused: no tool call or artifact since this task started]"
mark_done(task, result)
Sub-agents that return nothing but markup are marked as failed instead of being treated as findings. A worker that runs out of its tool budget is not simply given up on: the findings it gathered are synthesised by a bigger model, or the task is split into smaller parts and retried once. Reports get an evidence footer that marks claims with no tool output behind them.
The lesson of agent three: small models need ledgers, not trust. The model can be wrong all day long, as long as the system around it refuses to record a wrong claim as done.
Agent four: my current local AI agent #
The fourth agent has the same code base as the third. What changed is everything around it, and the change came from a single Saturday morning.
On 3 October an AI coding assistant was fixing a path bug in this very agent: its /delete command could escape its own folder. To prove that the new test caught the bug, the assistant ran the test against the old code, on the real machine. In about ten seconds my home folder was gone. I told that story in the test that deleted my home folder.
What matters here is what I did with the agent that afternoon. By 11:22 every one of its 12 services and timers was stopped and masked, so nothing could start them again. The scheduled jobs of v2 were commented out. For more than a day I had no agent at all.
When it came back on 4 October, it came back as a different citizen of the machine:
- its own system user, with no sudo, no Docker access and no access to my home folder; it cannot even list it;
- a sandboxed service: read-only system, no capabilities, a system-call filter, and outbound network denied except to the few places it needs;
- code installed root-owned from Git, so the agent cannot rewrite itself;
- a hard deny on every mutating tool, at first; then, step by step, a permission gate.
That gate is described in read, write, critical: permission tiers for a local AI agent. In short: every tool call is rated read, write or critical; writes need my approval, critical actions need a one-time code sent to my phone and bound to that exact operation, and anything running without a human present only proposes. Its procedures moved into plain Markdown playbooks, as in agent skills as SKILL.md playbooks. The scheduled reports from v2 moved over on 5 October, rebuilt to work without privileges: a read-only key for the intrusion-prevention engine, a database role that can only SELECT.
Today it can call about 540 tools. It reads and writes the shared roadmap where I record every task and mistake. Its repository holds 291 commits since June, 43 of them from the single day of its self-review. It still makes mistakes every day.
The lesson of agent four: the boundary lives outside the model. Not in the prompt, not in good behaviour, not in my trust. In a user account, a sandbox, a gate in the tool registry, and a code that only a separate process can issue.
The technical heart: one picture of four agents #
Each version moved trust one step further away from the model, into something a machine can check. Here is the whole story in one table.
| Where trust lived | What broke | What fixed it | |
|---|---|---|---|
| lsn-agent | in me, reading two code bases | duplicated code, silent failures, 0 tools | one shared core |
| lsn-agent-v2 | in the model’s summary | “all clear” from raw output | fact sheets computed by code |
| lsn-agent-v3 | in the model’s word that work was done | invented results, rubber-stamped tasks | a work ledger, evidence footers |
| current agent | in the operating system and one gate | a deletion running with my permissions | own user, sandbox, permission tiers |
Read the first column from top to bottom: from my own eyes, to the model’s word, to code, to the operating system.
What it cost #
Time, mostly. Four agents in the seven months my written record covers, on one machine. Two of them ran for long stretches doing work that was quietly wrong. And a bug in one of them, plus a test run in the wrong place, cost me my home folder.
And humility. Every one of these lessons was written down first as a mistake, with my name or an AI assistant’s next to it, in a record anyone with access can read. The model was wrong about counts, about what was deployed, about what it had done. I was wrong about what was running, about what a tool returned, about where to run a test. Writing it down is what turned each failure into the next design.
Questions people ask me #
Why build your own agent instead of using an existing one? #
Because I wanted it to run entirely on my own hardware and models, against my own tools, with my own rules for what it may touch. Building it also taught me where agents fail, and that knowledge is the real product. If you only need a coding helper, an existing tool is fine.
Can a 9B local model really run an agent? #
It can run the loop: call tools, read results, write a report. It cannot be trusted to judge its own work. With fact sheets, a ledger and bounded tasks, it finishes useful work. For multi-step reasoning over hundreds of tools, I route the hard steps to bigger local models.
Why rewrite instead of fixing the old code? #
Because the problem in agent one was structural: two programs doing one job. No amount of bug fixing removes a duplicated core. The rewrite took one day because the old version had already taught me exactly what the core needed to be.
What is the one design decision you would keep from all four? #
The agent loop that only yields typed events, from v2. It made every later feature possible: a terminal and a web interface from one core, sub-agents that report progress, sessions I can watch live from another device, and tests that run the real loop.
Where trust goes next #
The first agent answered its uptime monitor 1,023 times and nobody else. The fourth one asks my permission before it changes anything that matters, and proposes everything else when I am not around. Between them there are seven months, four code bases and a deleted home folder.
If you are building something similar, here is what I would tell you. Do not ask whether your agent is smart enough to trust. Ask where the trust actually lives, and move it, one version at a time, out of the model and into something you can check.