<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://olafkfreund.github.io/ravn-agents/feed.xml" rel="self" type="application/atom+xml" /><link href="https://olafkfreund.github.io/ravn-agents/" rel="alternate" type="text/html" /><updated>2026-06-22T09:42:43+00:00</updated><id>https://olafkfreund.github.io/ravn-agents/feed.xml</id><title type="html">Ravn</title><subtitle>Self-hosted self-healing for Linux fleets — deterministic detection, signed and auditable remediation, and AI that explains but never decides. Runs on hosts, Kubernetes, and air-gapped networks.</subtitle><entry><title type="html">Production hardening, and the bugs that only showed up when we combined the branches</title><link href="https://olafkfreund.github.io/ravn-agents/2026/06/21/production-hardening-and-the-bugs-that-hid.html" rel="alternate" type="text/html" title="Production hardening, and the bugs that only showed up when we combined the branches" /><published>2026-06-21T00:00:00+00:00</published><updated>2026-06-21T00:00:00+00:00</updated><id>https://olafkfreund.github.io/ravn-agents/2026/06/21/production-hardening-and-the-bugs-that-hid</id><content type="html" xml:base="https://olafkfreund.github.io/ravn-agents/2026/06/21/production-hardening-and-the-bugs-that-hid.html"><![CDATA[<p>The M6 hardening milestone is on <code class="language-plaintext highlighter-rouge">main</code>. The headline is unglamorous on purpose:
take the self-healing loop from “works in the demo” to “you could run this in a
regulated, air-gapped shop and trust the audit trail.” Fifteen issues, one epic
(<a href="https://github.com/olafkfreund/ravn-agents/issues/158">#158</a>).</p>

<p>The interesting part isn’t the feature list. It’s that <strong>four of the bugs we
found don’t exist in any single branch</strong> — they only appeared when the branches
were combined. That’s the honest argument for integrating deliberately instead
of merging fifteen green PRs and hoping.</p>

<h2 id="what-shipped">What shipped</h2>

<ul>
  <li><strong>Durable audit trail.</strong> The remediation record (proposal → decision → command
→ result) now lives in append-only Postgres, not memory. “Auditable” stops
being a slide when the record survives a control-plane restart. (<a href="https://github.com/olafkfreund/ravn-agents/issues/143">#143</a>)</li>
  <li><strong>An in-cluster Kubernetes executor.</strong> The pod-restart templates finally have
something that runs them: a component that pulls signed commands, <strong>re-verifies
the Ed25519 signature in-cluster</strong>, and executes typed capabilities
(<code class="language-plaintext highlighter-rouge">delete_pod</code>, <code class="language-plaintext highlighter-rouge">restart_deployment</code>, <code class="language-plaintext highlighter-rouge">pod_state</code>) under least-privilege,
namespaced RBAC. (<a href="https://github.com/olafkfreund/ravn-agents/issues/146">#146</a>)</li>
  <li><strong>Signing-key rotation</strong> with versioned keys and an overlap window — rotate the
command-signing key with no fleet re-enrollment. (<a href="https://github.com/olafkfreund/ravn-agents/issues/150">#150</a>)</li>
  <li><strong>Self-observability.</strong> Prometheus <code class="language-plaintext highlighter-rouge">/metrics</code> and a Ravn event when the policy
circuit breaker trips. The monitoring tool now monitors itself. (<a href="https://github.com/olafkfreund/ravn-agents/issues/149">#149</a>)</li>
  <li><strong>Air-gapped install</strong>, a non-NixOS host quickstart, a Helm chart, a real
<code class="language-plaintext highlighter-rouge">ravn-eval</code> benchmark harness, and an MCP server — the install story is now
first-class on host, Kubernetes, and fully offline.</li>
</ul>

<p>Through all of it, the founding rule is unchanged: <strong>the LLM is never in the
detection or action path.</strong> It writes the explanation; deterministic code and
signed templates decide and act.</p>

<h2 id="the-bugs-that-hid">The bugs that hid</h2>

<p>These are the useful part — each is invisible until two branches meet.</p>

<ul>
  <li>
    <p><strong>The guard that rejected its own executor.</strong> The in-cluster executor runs
commands <em>through</em> the shared <code class="language-plaintext highlighter-rouge">handle_command</code>. One branch added a guard there
that rejects Kubernetes capabilities — so the executor would have rejected
every command it exists to run. Its own unit tests passed, because they test
the guard and the executor <em>separately</em>, never composed. We moved the guard to
the host actuator’s socket boundary, where it belongs.</p>
  </li>
  <li>
    <p><strong>Two designs for one enum.</strong> Two branches independently invented the K8s
capability type — different variant names, different fields. Both compiled.
Reconciling them to one canonical design was a deliberate call, not a textual
merge.</p>
  </li>
  <li>
    <p><strong>A typed-path check that caught a real template typo.</strong> New startup validation
of template condition paths immediately rejected a shipped K8s template that
used <code class="language-plaintext highlighter-rouge">reason</code> instead of <code class="language-plaintext highlighter-rouge">payload.reason</code> — a template that, before the check,
would have silently <em>never matched</em>.</p>
  </li>
  <li>
    <p><strong>A <code class="language-plaintext highlighter-rouge">SIGKILL</code> that produced no proposal.</strong> This one only surfaced in the NixOS
VM end-to-end test. The test kills a unit with <code class="language-plaintext highlighter-rouge">SIGKILL</code>; systemd reports the
result as <code class="language-plaintext highlighter-rouge">signal</code>. But the generic restart template had been narrowed to match
only <code class="language-plaintext highlighter-rouge">result = "exit-code"</code>. So the unit failed, and Ravn proposed <em>nothing</em> —
the worst kind of failure, the silent one. The fix was to match any failed unit
regardless of result, and to lock it in with a test that fails the moment the
catch-all gets narrowed again.</p>
  </li>
</ul>

<p>Every one of those passed <code class="language-plaintext highlighter-rouge">cargo test</code> on its own branch. The VM test — boot a
real NixOS machine, kill a unit, assert a signed heal record appears — is the only
thing that catches the last one. Slow tests earn their keep.</p>

<h2 id="a-smaller-sharper-one-is-it-broken-or-just-deploying">A smaller, sharper one: “is it broken, or just deploying?”</h2>

<p>A question we kept getting: how does Ravn avoid “healing” something that’s just
being restarted or redeployed? Most of the answer is state semantics — systemd’s
<code class="language-plaintext highlighter-rouge">failed</code> state and Kubernetes’ failure <em>reasons</em> don’t fire for clean restarts or
rollouts. But a unit can briefly enter <code class="language-plaintext highlighter-rouge">failed</code> before systemd’s own <code class="language-plaintext highlighter-rouge">Restart=</code>
recovers it. So the failed-unit tap now has a <strong>grace period</strong>: a unit must stay
failed for a configurable window (default 15s) before it emits anything. Brief
blip, recovered in time → no proposal, no noise. (<a href="https://github.com/olafkfreund/ravn-agents/issues/164">#164</a>)</p>

<h2 id="whats-next">What’s next</h2>

<p>The code is on <code class="language-plaintext highlighter-rouge">main</code> and green; the cross-target <em>end-to-end</em> proofs (k3d heal,
air-gapped VM, live-Postgres restart) are running their way through CI now, and
that’s the bar for calling the epic done — not “it compiles.” There’s also a
copy-paste <a href="https://github.com/olafkfreund/ravn-agents/tree/main/examples/remediation-templates">remediation template library</a>
now, so adding a heal for <em>your</em> service is a TOML file, not a code change.</p>

<p>As always, built in the open. The bugs above are in the git history, not hidden
in a changelog — that’s the whole point.</p>]]></content><author><name>Olaf</name></author><category term="devlog" /><category term="m6" /><category term="remediation" /><category term="kubernetes" /><category term="decision" /><category term="retro" /><summary type="html"><![CDATA[M6 lands: durable audit, an in-cluster K8s executor, signing-key rotation, self-observability, air-gapped install — and four real bugs that no single PR review could have caught.]]></summary></entry><entry><title type="html">Watch it heal itself: a one-box, GPU-accelerated Ravn demo</title><link href="https://olafkfreund.github.io/ravn-agents/2026/06/06/watch-it-heal-itself.html" rel="alternate" type="text/html" title="Watch it heal itself: a one-box, GPU-accelerated Ravn demo" /><published>2026-06-06T00:00:00+00:00</published><updated>2026-06-06T00:00:00+00:00</updated><id>https://olafkfreund.github.io/ravn-agents/2026/06/06/watch-it-heal-itself</id><content type="html" xml:base="https://olafkfreund.github.io/ravn-agents/2026/06/06/watch-it-heal-itself.html"><![CDATA[<p>We’ve talked a lot about what Ravn <em>is</em> — small local-LLM agents that watch
Linux/NixOS hosts and Kubernetes, report in plain language, and heal things with
your approval. This post is about being able to <strong>see all of it at once</strong>, on one
laptop, with a single command:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scripts/demo-up.sh
</code></pre></div></div>

<p>That brings up a systemd “NixOS host” (running the agent + the privileged
actuator + a deliberately flaky unit), a k3d cluster (the Ravn controller plus a
node-agent and a spread of healthy and failing pods), a self-contained Ollama for
the explanations, and the portal — all on one shared network. Then
<code class="language-plaintext highlighter-rouge">scripts/demo-remediate.sh --auto</code> fails a unit and heals it end to end while you
watch.</p>

<p>It works now. Getting there surfaced a string of small, honest problems that are
worth writing down — they’re the useful part.</p>

<h2 id="seeing-the-fleet">Seeing the fleet</h2>

<p>The topology view now groups the fleet by any label you like and draws an icon per
<em>kind</em> of thing, so a Kubernetes cluster and a bare host read as two different
animals at a glance:</p>

<p><img src="/ravn-agents/assets/img/blog-topology.png" alt="Topology grouped into a k3d cluster and a NixOS host" /></p>

<p>That’s the <strong>☸ k3d-cluster</strong> group (the controller agent, currently red because its
pods are crashlooping) next to the <strong>❄ nixos</strong> group (the host, green and healthy
after its last heal). The portal auto-selects the most useful grouping dimension on
load, so you land on this instead of one undifferentiated blob.</p>

<h2 id="the-last-mile-plain-language-translations">The last mile: plain-language translations</h2>

<p>Deterministic tooling decides <em>whether</em> something is wrong — failed-unit state,
CrashLoopBackOff, OOMKilled, ImagePullBackOff. The model’s only job is the last
mile: turning that flagged event into a sentence you can act on. Two paths feed it,
both pointed at the same local Ollama:</p>

<ul>
  <li><strong>Host events</strong> (failed units, journald) are explained by the agent, inline,
before the event is even published.</li>
  <li><strong>Kubernetes events</strong> are explained by the control plane, asynchronously, so
ingestion never waits on the model.</li>
</ul>

<p>Click any event and you get the translation plus one concrete check to run:</p>

<p><img src="/ravn-agents/assets/img/blog-host-explanation.png" alt="A failed systemd unit, explained in plain language with a suggested check" /></p>

<blockquote>
  <p><em>“The flaky.service failed due to an infinite loop, as it was terminated by a
signal but didn’t exit, leading to resource contention…”</em> — with a suggested
<code class="language-plaintext highlighter-rouge">journalctl -u flaky.service</code>, generated locally by Qwen3 1.7B.</p>
</blockquote>

<p>If the model is slow or down, you still get the alarm — you just get a worse
explanation. Safe failure, as always.</p>

<h2 id="supervised-self-healing">Supervised self-healing</h2>

<p>When a detected fault matches a curated template, the control plane proposes a fix.
You approve it; the control plane signs an Ed25519 command; the agent pulls it, the
privileged actuator runs it, and the result is recorded at-most-once. Here’s the
Remediations page after a few cycles — nothing pending, a clean history of
<code class="language-plaintext highlighter-rouge">failed-unit-restart</code> actions that each went <strong>approved → succeeded (active)</strong>:</p>

<p><img src="/ravn-agents/assets/img/blog-remediations.png" alt="Remediations page: a clean history of approved, succeeded heals" /></p>

<p>The whole loop — kill a unit, watch the proposal appear, approve, watch it come
back — runs in a few seconds.</p>

<h2 id="the-problems-we-hit-the-useful-part">The problems we hit (the useful part)</h2>

<p>Almost none of these were in the “product.” They were in making a distributed
system pretend to be one laptop.</p>

<ul>
  <li><strong>The overlay footgun.</strong> A bare <code class="language-plaintext highlighter-rouge">docker compose up</code> silently dropped the demo
overlay, which disabled remediation templates — so the agent detected the fault,
proposed nothing, and everything <em>looked</em> fine. Fix: <code class="language-plaintext highlighter-rouge">demo-up.sh</code> always passes
every overlay, and the README warns in bold.</li>
  <li><strong>Ollama on <code class="language-plaintext highlighter-rouge">127.0.0.1</code>.</strong> No container — control plane, host agent, or k3d —
could reach a host Ollama bound to loopback. That’s why explanations never
appeared at first. Fix: a self-contained <strong>Ollama sidecar</strong> on the shared
network; nothing on your host required.</li>
  <li><strong>CPU contention starved the explanations.</strong> On CPU, one inference stream
couldn’t keep up with the deliberately chatty demo, and the most useful event —
the failed unit — kept timing out. Putting Ollama on the <strong>AMD GPU via ROCm</strong>
(a 7900 XT, <code class="language-plaintext highlighter-rouge">ollama ps</code> → <code class="language-plaintext highlighter-rouge">100% GPU</code>) took it to instant and 100% coverage. The
demo auto-detects ROCm / NVIDIA / CPU; the README documents all three plus other
GPUs and reusing an external Ollama.</li>
  <li><strong>A kernel-trace flood.</strong> Because the host container and the k3d cluster share
the laptop’s kernel, the cluster’s OOM stack-traces landed in the <em>host’s</em>
journald and buried the real service events. New <code class="language-plaintext highlighter-rouge">RAVN_JOURNALD_SKIP_KERNEL</code>
option drops raw kernel ring-buffer noise. (On real, separate hosts this never
arises.)</li>
  <li><strong>Duplicate topology nodes.</strong> Every agent restart minted a fresh ID, leaving
offline ghosts in the topology. Fix: a stable <code class="language-plaintext highlighter-rouge">RAVN_AGENT_ID</code> (the host agent’s
counterpart to the controller’s fixed ID), so redeploys reuse the same node and
keep their labels.</li>
  <li><strong>nginx OOMing on a big box.</strong> The “healthy” baseline pods crashlooped because
nginx spins up one worker per core — dozens on a Threadripper — and blew past a
small memory limit. Capped the workers; baseline is green again.</li>
</ul>

<h2 id="try-it">Try it</h2>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scripts/demo-up.sh                 <span class="c"># control plane + host + k3d + Ollama (auto GPU)</span>
scripts/demo-remediate.sh <span class="nt">--auto</span>   <span class="c"># fail a unit, approve the fix, watch it heal</span>
</code></pre></div></div>

<p>Then open the portal at <a href="http://localhost:8088/topology">http://localhost:8088/topology</a>. Full instructions,
including GPU setup for AMD/NVIDIA/CPU, are in
<a href="https://github.com/olafkfreund/ravn-agents/blob/main/demo/README.md"><code class="language-plaintext highlighter-rouge">demo/README.md</code></a>.</p>

<p>As always: the model never decides whether something is wrong. It just tells you,
in plain words, what already got flagged — and now it does it fast, on a GPU, while
the fleet heals itself in front of you.</p>]]></content><author><name>Olaf</name></author><category term="devlog" /><category term="demo" /><category term="kubernetes" /><category term="self-healing" /><category term="llm" /><summary type="html"><![CDATA[A self-contained demo — a NixOS host, a k3d cluster, local LLM explanations, and live self-healing — plus the half-dozen real problems we hit getting it to run on one machine.]]></summary></entry><entry><title type="html">Hello from Ravn</title><link href="https://olafkfreund.github.io/ravn-agents/2026/06/02/welcome-to-ravn.html" rel="alternate" type="text/html" title="Hello from Ravn" /><published>2026-06-02T00:00:00+00:00</published><updated>2026-06-02T00:00:00+00:00</updated><id>https://olafkfreund.github.io/ravn-agents/2026/06/02/welcome-to-ravn</id><content type="html" xml:base="https://olafkfreund.github.io/ravn-agents/2026/06/02/welcome-to-ravn.html"><![CDATA[<p>Ravn started as a simple question: could you bolt a tiny language model onto a
Linux server — CPU only, no GPU — and have it review logs and services, then tell
you in plain words when something’s wrong?</p>

<p>The short answer is yes, with one important twist. The model should <strong>not</strong> be the
thing that decides whether something is wrong. Deterministic tooling already does
that job well — journald, failed-unit state, config diffs, auth events. What’s
missing is the <em>last mile</em>: turning a flagged event into a clear sentence a human
can act on. That’s the job we’re handing to a small model (think Qwen3 1.7B), and
it’s a job a CPU handles comfortably. If the model is slow or wrong, you still get
the alarm — you just get a worse explanation. Safe failure.</p>

<p>From there it grew. One agent became a fleet. A fleet needs somewhere to report to,
so there’s a control plane. And a fleet you can’t <em>see</em> isn’t much use, so there’s a
portal: inventory, a live message feed, and a topology view where you group your
agents into whatever categories make sense to you — environment, role, business unit,
location.</p>

<p>We’re building this in the open, MIT-licensed, because it’s the kind of tool that
gets better with other people’s weird hardware, odd log formats, and hard-won
operational scars. The <a href="/ravn-agents/roadmap/">roadmap</a> lays out what, how and when,
from a walking skeleton (M0) through to alert routing and an eval harness (M5).</p>

<p>This blog is the devlog. Expect progress, design decisions, and — honestly — the
struggles too. The dead ends are usually the useful part. Comments are open on every
post (they’re backed by GitHub Discussions), so if you’ve solved something we’re
stuck on, or just want to argue about transport protocols, jump in.</p>

<p>If you want to get involved, the <a href="https://github.com/olafkfreund/ravn-agents/issues">issues</a>
are organised by epic with <code class="language-plaintext highlighter-rouge">good first issue</code> labels, and
<a href="https://github.com/olafkfreund/ravn-agents/discussions">Discussions</a> is open for ideas.</p>

<p>More soon. 🐦‍⬛</p>]]></content><author><name>Olaf</name></author><category term="devlog" /><category term="intro" /><summary type="html"><![CDATA[Why we're building a fleet of small local-LLM agents to watch Linux servers — and building it in the open.]]></summary></entry></feed>