<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title>Isak Labuscagne — Blog</title>
  <link>https://network-ai.co.za</link>
  <description>Infrastructure, automation and practical AI.</description>
  <language>en-ZA</language>
  <atom:link href="https://network-ai.co.za/blog/rss.xml" rel="self" type="application/rss+xml"/>
  
  <item>
    <title>The $5 Chip on My Desk Doesn&#x27;t Know Anything — And That&#x27;s the Design</title>
    <link>https://network-ai.co.za/blog/esp32-desk-buddy.html</link>
    <guid>https://network-ai.co.za/blog/esp32-desk-buddy.html</guid>
    <pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate>
    <description>I wanted a thing on my desk that answers questions. The obvious chip is a $5 ESP32-S3 — so I went to find out what it can actually do, and ended up building the brain somewhere else entirely.</description>
    <content:encoded><![CDATA[&lt;p&gt;I wanted a thing on my desk that answers questions. Not a phone app, not a browser tab — a small object with a microphone that I can ask something without touching anything.&lt;/p&gt;
&lt;p&gt;The obvious candidate is an ESP32. A few dollars, WiFi built in, runs Linux-adjacent firmware, and there&#x27;s a whole ecosystem of open-source voice assistants for it. Also — and this is the part I got wrong — I assumed the chip would be doing the thinking.&lt;/p&gt;
&lt;p&gt;It cannot. Not even slightly. Understanding &lt;em&gt;why&lt;/em&gt; is what this post is actually about, because the fix turned out to be a design principle I already believe in.&lt;/p&gt;
&lt;h2&gt;The chip got very good at running a language model&lt;/h2&gt;
&lt;p&gt;First, the part that genuinely surprised me. The state of on-device LLM work on microcontrollers moved a long way recently, and the number that made me sit up was this: a 28.9-million-parameter language model running on an ESP32-S3, generating text at 9.88 tokens per second, entirely on the chip, with nothing sent to a server. On an $8 board.&lt;/p&gt;
&lt;p&gt;For context, the previous record for a chip this size was 260,000 parameters. That&#x27;s roughly a hundredfold jump in about three years.&lt;/p&gt;
&lt;p&gt;The trick is memory tiering. An ESP32-S3 gives you 512KB of fast SRAM, up to 8MB of PSRAM, and 16MB of flash. None of that is enough to hold a real model, so the trick is deciding what &lt;em&gt;doesn&#x27;t&lt;/em&gt; need to be fast:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;What lives there&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;th&gt;Access pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SRAM (512KB)&lt;/td&gt;
&lt;td&gt;Activations, norm weights — the actual thinking&lt;/td&gt;
&lt;td&gt;Fastest&lt;/td&gt;
&lt;td&gt;Touched every single token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PSRAM (8MB, octal)&lt;/td&gt;
&lt;td&gt;Output head + KV cache&lt;/td&gt;
&lt;td&gt;60.7 MB/s measured&lt;/td&gt;
&lt;td&gt;Read once per position&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flash (16MB)&lt;/td&gt;
&lt;td&gt;A 25M-parameter lookup table&lt;/td&gt;
&lt;td&gt;Slow, and it doesn&#x27;t matter&lt;/td&gt;
&lt;td&gt;~6 random 512B rows per token&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The insight is borrowed from Google&#x27;s Gemma models: most of a language model&#x27;s parameters live in an embedding table that you &lt;em&gt;read from&lt;/em&gt; rather than &lt;em&gt;compute on&lt;/em&gt;. So you leave that table in flash and pull only the few rows each token needs — about 450 bytes. The expensive thing is never loaded; it just sits there being sampled a little at a time.&lt;/p&gt;
&lt;p&gt;The numbers that make it work are worth knowing, because they tell you where the ceiling actually is. That flash table costs 20.3 microseconds per row lookup, six lookups per token, so about 0.12ms — roughly 0.7% of the per-token memory traffic. Free. The real bottleneck is entirely different: the output head, a matrix-vector multiply over 1.5MB of weights. At 60.7 MB/s the theoretical floor is 24.7ms, and the achieved number is 57.6ms.&lt;/p&gt;
&lt;p&gt;Which is why the optimisation ladder looks like this:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First portable port&lt;/td&gt;
&lt;td&gt;0.57 tok/s&lt;/td&gt;
&lt;td&gt;Scalar FP32, head resident in flash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staged head in PSRAM&lt;/td&gt;
&lt;td&gt;4.77 tok/s&lt;/td&gt;
&lt;td&gt;Stop bit-shifting 4-bit nibbles per token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dual-core head&lt;/td&gt;
&lt;td&gt;6.22 tok/s&lt;/td&gt;
&lt;td&gt;Moved the head to core 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Int8 staged head&lt;/td&gt;
&lt;td&gt;9.72 tok/s&lt;/td&gt;
&lt;td&gt;Integer dot products, no float at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A 17x speedup, and every step of it was about &lt;em&gt;data placement&lt;/em&gt;, not about adding compute power. That&#x27;s the whole story of embedded inference.&lt;/p&gt;
&lt;h2&gt;And it still cannot answer a single question&lt;/h2&gt;
&lt;p&gt;Here is the caveat that the press release skipped, and it&#x27;s the reason this post exists. That model was trained on TinyStories. It writes short, simple, fictional stories. It will not answer questions, it will not follow instructions, it will not write your Python, and it has zero factual knowledge about the real world.&lt;/p&gt;
&lt;p&gt;That&#x27;s not a bug in the implementation. That&#x27;s what 28.9 million parameters &lt;em&gt;is&lt;/em&gt;. There is no arrangement of memory layout that turns a model trained to tell bedtime stories into something that knows what the weather is doing in Cape Town.&lt;/p&gt;
&lt;p&gt;For completeness, the other end of the spectrum is just as interesting. There&#x27;s a 118,784-parameter model that runs on a $2 board with &lt;strong&gt;zero&lt;/strong&gt; PSRAM, hits 18.24 tok/s, keeps a 24.5KB KV cache in internal SRAM, and serves a chat UI over a WiFi hotspot. Faster than the 28.9M one, and knows even less. It has 128 tokens of vocabulary.&lt;/p&gt;
&lt;p&gt;So: the honest summary of &quot;AI in microcontrollers&quot; in 2026 is that we got astonishingly good at &lt;em&gt;generating believable text on a chip&lt;/em&gt;, and that has almost nothing to do with &lt;em&gt;answering questions&lt;/em&gt;. Those are different problems wearing the same coat.&lt;/p&gt;
&lt;h2&gt;So the chip is the interface, not the brain&lt;/h2&gt;
&lt;p&gt;The build I ended up with is a split, and once you accept the split it stops being a compromise and starts being the obvious design:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stays on the ESP32-S3&lt;/th&gt;
&lt;th&gt;Goes to a public AI model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wake word detection&lt;/td&gt;
&lt;td&gt;Everything requiring knowledge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microphone capture + noise suppression&lt;/td&gt;
&lt;td&gt;Speech to text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speaker playback + TTS streaming&lt;/td&gt;
&lt;td&gt;The actual answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The personality (LED states, voice, timing)&lt;/td&gt;
&lt;td&gt;Long-tail reasoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local commands, sensors, presence&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The offline fallback&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The left column is a list of things a microcontroller is genuinely good at: streaming audio over I2S, holding state, driving a GPIO, and staying awake at 5mA while listening. None of that needs a transformer.&lt;/p&gt;
&lt;p&gt;Wake word detection is the clearest example of a job that &lt;em&gt;belongs&lt;/em&gt; on the chip. Espressif&#x27;s own wake word engine runs fully offline in under 200ms, and because it&#x27;s local it works with the WiFi down, on a flat battery, in a room with no signal. That last property is the entire reason it stays on the chip — a wake word that needs a network round trip is a button.&lt;/p&gt;
&lt;p&gt;The voice stack is ordinary microcontroller work: an I2S MEMS microphone in, an I2S amplifier out, 16kHz mono, Opus-compressed over a WebSocket. The open-source &lt;code&gt;xiaozhi-esp32&lt;/code&gt; project (MIT, ~25k stars) has already built most of this, including device-side MCP so a model can turn &quot;turn the light down&quot; into a GPIO write without anyone writing voice-parsing code.&lt;/p&gt;
&lt;p&gt;Where the time actually goes is worth a table, because it tells you which part to optimise:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Microphone capture&lt;/td&gt;
&lt;td&gt;10ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local preprocessing&lt;/td&gt;
&lt;td&gt;20ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wake word (on-chip)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speech to text (remote)&lt;/td&gt;
&lt;td&gt;300ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model inference (remote)&lt;/td&gt;
&lt;td&gt;800ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text to speech (remote)&lt;/td&gt;
&lt;td&gt;400ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speaker playback&lt;/td&gt;
&lt;td&gt;50ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1.66s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The chip contributes 110ms. The network contributes 1.5 seconds. Optimising the microcontroller is not the lever here, and pretending otherwise is how people end up spending a weekend on firmware when they should have been shopping for a different model.&lt;/p&gt;
&lt;h2&gt;The public models are a rental, not a foundation&lt;/h2&gt;
&lt;p&gt;&quot;Public AI models&quot; is doing a lot of work in the phrase, so let me be concrete about what it actually is. Here&#x27;s the state of the free tiers as I found them:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Free allowance&lt;/th&gt;
&lt;th&gt;The catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Groq&lt;/td&gt;
&lt;td&gt;30 req/min, 1,000 req/day, 200K tokens/day&lt;/td&gt;
&lt;td&gt;Open-weight only; the token cap binds first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenRouter&lt;/td&gt;
&lt;td&gt;20 req/min, 50 req/day&lt;/td&gt;
&lt;td&gt;Roster churns constantly; 1,000/day after a one-off $10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Workers AI&lt;/td&gt;
&lt;td&gt;10,000 neurons/day&lt;/td&gt;
&lt;td&gt;Budget is in neurons, not tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Gemini&lt;/td&gt;
&lt;td&gt;Flash tier free&lt;/td&gt;
&lt;td&gt;Vendor states free-tier content is used to improve their products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral&lt;/td&gt;
&lt;td&gt;$10/month of credits&lt;/td&gt;
&lt;td&gt;Training on by default in Free mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cohere&lt;/td&gt;
&lt;td&gt;1,000 calls/month&lt;/td&gt;
&lt;td&gt;Non-commercial only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two arithmetic details that matter more than the table. First, Groq&#x27;s 1,000 requests a day sounds generous until you notice the 200,000 token ceiling lands at an average of 200 tokens per request — fine for a desk toy, brutal for anything carrying context. Second, every one of these numbers moves, and not always slowly: GitHub Models was retired entirely in July 2026, Cerebras replaced its free tier with a card-required trial, and Llama quietly left Groq&#x27;s free tier in August.&lt;/p&gt;
&lt;p&gt;That&#x27;s the lesson, and it&#x27;s the same one as the push URLs in the monitoring post. &lt;strong&gt;A free tier is a rental agreement, not a foundation.&lt;/strong&gt; You can absolutely live on it — you just have to know it&#x27;s someone else&#x27;s building, and that they can demolish it on a Tuesday. So: two providers configured, fallback on a 429, and a cache in front of both.&lt;/p&gt;
&lt;h2&gt;The part that actually matters&lt;/h2&gt;
&lt;p&gt;Here&#x27;s what I got wrong at the start. I designed the buddy as a thin client: hear a question, send it, play the answer. Which means when the network is down, the object on my desk is a brick that costs me money to keep alive.&lt;/p&gt;
&lt;p&gt;And &lt;em&gt;that&#x27;s the exact moment you ask it what&#x27;s going on.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;So the same rule from the monitoring post applies, and I should have seen it coming: the witness must not live on the thing being watched. The chip cannot be a dependent of the API. Concretely, four tiers of degraded behaviour:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cached answers&lt;/strong&gt; — a local store of things I actually ask it often. A question normalised to a hash, answered once, replayed forever. This is the cheapest rate-limit relief there is, and it doubles as the offline mode.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local intent&lt;/strong&gt; — volume, lights, timers, &quot;what time is it&quot;, &quot;stop talking&quot;. Never leave the network for a command that didn&#x27;t need it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local presence&lt;/strong&gt; — the chip knows when someone&#x27;s in the room, what the temperature is, whether it&#x27;s dark. That context is on the device because it&#x27;s on the device, and it doesn&#x27;t need permission to leave.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An honest offline state&lt;/strong&gt; — when the network is gone, it says so, in one sentence, and then still answers the things it can. A device that lies about why it can&#x27;t help is worse than one that goes quiet.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The LED is the tell. Green pulse means the model answered live. Amber means cached. Steady blue means offline and it knows it. You can read the state of the whole system off one diode on your desk, which is more than most of my actual infrastructure manages.&lt;/p&gt;
&lt;h2&gt;The key is on the device&lt;/h2&gt;
&lt;p&gt;Worth stating plainly, because it&#x27;s the part people skip: that API key is sitting in flash on a $5 device on your desk, and the device will eventually be lost, stolen, reflashed, or handed to someone. Provision per-device keys, not one global key. Scope them. Rotate them. Assume the one on the desk is already public, because a hardware token in an esptool image is not a secret — it&#x27;s a string in a firmware binary anyone can pull over the air.&lt;/p&gt;
&lt;h2&gt;What it cost&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Money:&lt;/strong&gt; about $35 total, one-off. The board was $5, the microphone and amplifier a few dollars, the enclosure more than the electronics.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tokens:&lt;/strong&gt; free tiers, comfortably, because a person asks a desk toy maybe twenty questions a day and the cache eats most of them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Power:&lt;/strong&gt; ~5mA idle listening, ~150mA awake. It&#x27;s a USB-powered object, not a battery problem, and that&#x27;s a decision I made on day one by not using one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time:&lt;/strong&gt; far more than the electronics. The firmware was the cheap part.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Lessons&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Running a language model and answering questions are different problems.&lt;/strong&gt; 28.9 million parameters generate lovely nonsense. Check what a model was &lt;em&gt;trained&lt;/em&gt; on before you build a product on top of it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On tiny hardware, data placement beats compute.&lt;/strong&gt; 0.57 to 9.72 tokens/second came entirely from memory layout and integer maths, not from a faster chip.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Put the latency where the latency belongs.&lt;/strong&gt; 110ms of the 1.66s was the microcontroller. Optimise the chip when it&#x27;s on the critical path, and not before.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the interface local and the intelligence remote.&lt;/strong&gt; Wake word, sensors, personality and the offline path stay on the silicon because that&#x27;s what silicon is for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The offline path isn&#x27;t a nice-to-have — it&#x27;s the use case.&lt;/strong&gt; The moment your remote brain is unavailable is the moment someone asks your device what the problem is.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A free tier is a rental.&lt;/strong&gt; Keep a second provider, cache aggressively, and assume any published limit is a suggestion with a countdown.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hardware tokens leak.&lt;/strong&gt; Per-device keys, scoped, rotated, and treated as public from the day you flash them.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;My desk buddy is a $5 chip that knows nothing. What it actually is, is a very good sensor with excellent manners and a good fallback — it notices I&#x27;m here, it hears me, and it either knows the answer or tells me honestly that it can&#x27;t.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The chip is the part you can trust. The model is the part you&#x27;re borrowing.&lt;/em&gt;&lt;/p&gt;]]></content:encoded>
    <category>esp32</category>
  </item>
  <item>
    <title>If You Can&#x27;t Monitor It, You Don&#x27;t Own It</title>
    <link>https://network-ai.co.za/blog/if-you-cant-monitor-it.html</link>
    <guid>https://network-ai.co.za/blog/if-you-cant-monitor-it.html</guid>
    <pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate>
    <description>How I built uptime monitoring that survives the failure it&#x27;s watching — Uptime Kuma on a separate node, a 20-line health gate as a dead man&#x27;s switch, and alerts that route through n8n.</description>
    <content:encoded><![CDATA[&lt;p&gt;Server access logs tell me who visited this site. Nothing told me whether they could.&lt;/p&gt;
&lt;p&gt;Two weeks ago the disk on this box filled to 100% and nginx started returning 502s for the site itself. I knew because I kept re-curling it between fixes. That&#x27;s not monitoring — that&#x27;s a habit. If I&#x27;d gone to lunch, nobody would have known until I came back.&lt;/p&gt;
&lt;p&gt;The homelab page has had &quot;monitoring dashboard&quot; sitting on the backlog for months. This is that — minus the dashboard vanity. The part that actually matters is the part that wakes you up.&lt;/p&gt;
&lt;h2&gt;The trap: a monitor that shares a failure domain&lt;/h2&gt;
&lt;p&gt;The obvious move was one command right next to the site itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run -d --restart unless-stopped -p 127.0.0.1:3001:3001 \
  -v uptime-kuma:/app/data louislam/uptime-kuma:1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same box, same Docker, one &lt;code&gt;compose pull&lt;/code&gt; away. And completely worthless for the failures I actually fear. OOM killer, swap death, a bad kernel update, an IAM rule that locks me out of the instance — every one of those takes the monitor down with the thing it&#x27;s watching. A green screenshot isn&#x27;t monitoring. Monitoring is a witness that survives the crime.&lt;/p&gt;
&lt;p&gt;Failures come in two classes, and they need different vantage points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Service dead, box alive&lt;/strong&gt; — the 502 case. nginx is running, the disk is full, certbot is failing quietly, and the box hums along looking fine to anything on it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Box dead&lt;/strong&gt; — everything stops, including any monitor unlucky enough to be on it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One box can&#x27;t reliably witness either of its own failures. So the rule became: &lt;strong&gt;the thing that watches must not live on the thing being watched.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The layout&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Runs on&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Health-gate heartbeat&lt;/td&gt;
&lt;td&gt;cron on the EC2&lt;/td&gt;
&lt;td&gt;Box death, cron death, broken outbound network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP checks (site, n8n endpoint)&lt;/td&gt;
&lt;td&gt;Uptime Kuma on the separate node&lt;/td&gt;
&lt;td&gt;nginx/TLS/DNS/502s — observed from outside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alert routing&lt;/td&gt;
&lt;td&gt;n8n on that same node&lt;/td&gt;
&lt;td&gt;Turns DOWN into a Telegram message that waits its turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outer ring: one free external monitor&lt;/td&gt;
&lt;td&gt;SaaS, watching the status page&lt;/td&gt;
&lt;td&gt;Death of the node that runs Kuma and n8n&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The separate node is the same one from the n8n post — the whole reason n8n doesn&#x27;t sit beside the site itself. Monitoring inherits that reasoning: different failure domain or it doesn&#x27;t count.&lt;/p&gt;
&lt;h2&gt;The health gate: the only thing that runs on the box&lt;/h2&gt;
&lt;p&gt;The heartbeat isn&#x27;t a ping. A ping says &quot;the kernel still answers.&quot; I want &quot;the things that rot quietly are still fine&quot; — so it&#x27;s a 20-line bash script that checks nginx, the cert expiry, the disk and the last successful deploy, then pushes one overall status to Kuma&#x27;s push API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
set -u
BASE=&amp;quot;https://status.network-ai.co.za/api/push/${KUMA_TOKEN}&amp;quot;
fail=()

systemctl is-active --quiet nginx || fail+=(&amp;quot;nginx-down&amp;quot;)

# a 502 on the site itself is the failure that actually matters
curl -fsS -o /dev/null --max-time 10 https://network-ai.co.za/ \
  || fail+=(&amp;quot;site-not-200&amp;quot;)

cert=/etc/letsencrypt/live/network-ai.co.za/cert.pem
expiry=$(openssl x509 -enddate -noout -in &amp;quot;$cert&amp;quot; | cut -d= -f2)
days=$(( ( $(date -d &amp;quot;$expiry&amp;quot; +%s) - $(date +%s) ) / 86400 ))
(( days &amp;lt; 14 )) &amp;amp;&amp;amp; fail+=(&amp;quot;cert-expires-in-${days}d&amp;quot;)

(( $(df -P / | awk &#x27;NR==2{print $5}&#x27; | tr -d %) &amp;gt; 90 )) &amp;amp;&amp;amp; fail+=(&amp;quot;disk-over-90&amp;quot;)

if (( ${#fail[@] == 0 )); then
  curl -fsSG --max-time 10 &amp;quot;$BASE&amp;quot; --data-urlencode &amp;quot;status=up&amp;quot; --data-urlencode &amp;quot;msg=ok&amp;quot;
else
  msg=$(IFS=&#x27;|&#x27;; echo &amp;quot;${fail[*]}&amp;quot;)
  curl -fsSG --max-time 10 &amp;quot;$BASE&amp;quot; --data-urlencode &amp;quot;status=down&amp;quot; --data-urlencode &amp;quot;msg=$msg&amp;quot;
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the dead-man&#x27;s-switch part is free: if the script dies, if cron dies, if the box dies — &lt;strong&gt;the push stops arriving&lt;/strong&gt;, and Kuma marks the monitor DOWN anyway. The failure mode fails safe. That&#x27;s the whole trick. A heartbeat you have to poll for is a chore; a heartbeat that expires on its own is a witness.&lt;/p&gt;
&lt;p&gt;One cron line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;* * * * * /usr/local/bin/health-gate.sh &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--max-time 10&lt;/code&gt; matters more than it looks. A hung &lt;code&gt;curl&lt;/code&gt; without a timeout stacks a new run every minute until the box is out of processes — exactly the kind of self-inflicted outage this script exists to catch.&lt;/p&gt;
&lt;h2&gt;Kuma on the other node&lt;/h2&gt;
&lt;p&gt;Uptime Kuma itself lives on the separate node, behind nginx with TLS, bound to localhost like everything else:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    volumes:
      - uptime-kuma:/app/data
    restart: unless-stopped
    ports:
      - &amp;quot;127.0.0.1:3001:3001&amp;quot;
volumes:
  uptime-kuma:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three monitors, two failure domains:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Push monitor&lt;/strong&gt; — the health gate&#x27;s heartbeat, 60-second grace.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTPS: network-ai.co.za&lt;/strong&gt; — catches nginx death, expired TLS, DNS pointed somewhere stupid, and the 502 class from &lt;em&gt;outside&lt;/em&gt;, which is the exact blind spot from the day the disk filled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTPS: the n8n webhook endpoint&lt;/strong&gt; — because an alerting pipeline nobody checks is the most optimistic kind of monitoring.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Kuma&#x27;s built-in status page goes on a subdomain — &lt;code&gt;status.network-ai.co.za&lt;/code&gt; — and deliberately lives off the broken box. When the site is down, the status page is the one thing still up telling people so. It replaces the &quot;is it just me?&quot; messages I used to get on WhatsApp, which is the MSP version of this same problem.&lt;/p&gt;
&lt;h2&gt;Turning DOWN into an actual page&lt;/h2&gt;
&lt;p&gt;Kuma&#x27;s webhook feeds n8n — the same instance from the automation post. But a raw DOWN-to-Telegram pipeline trains you to ignore alerts the first time something flaps during a &lt;code&gt;docker compose pull&lt;/code&gt;. So the workflow waits, re-checks, and only pages if the failure was real:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;trigger&amp;quot;: &amp;quot;webhook: kuma.monitor.down&amp;quot;,
  &amp;quot;steps&amp;quot;: [
    { &amp;quot;action&amp;quot;: &amp;quot;wait&amp;quot;, &amp;quot;seconds&amp;quot;: 60 },
    { &amp;quot;action&amp;quot;: &amp;quot;http_request&amp;quot;, &amp;quot;url&amp;quot;: &amp;quot;kuma /api/monitor/{id}&amp;quot;, &amp;quot;expect&amp;quot;: &amp;quot;status=down&amp;quot; },
    { &amp;quot;action&amp;quot;: &amp;quot;if_still_down&amp;quot;, &amp;quot;then&amp;quot;: [&amp;quot;notify_telegram&amp;quot;, &amp;quot;notify_email&amp;quot;] },
    { &amp;quot;action&amp;quot;: &amp;quot;otherwise&amp;quot;, &amp;quot;then&amp;quot;: [&amp;quot;log_as_flap&amp;quot;] }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The DOWN message from the push monitor carries the health gate&#x27;s reason string — &lt;code&gt;cert-expires-in-12d&lt;/code&gt; or &lt;code&gt;disk-over-90&lt;/code&gt; — so the first message is already the diagnosis, not just &quot;something is wrong, go look.&quot;&lt;/p&gt;
&lt;p&gt;And per the rule from the n8n post: the whole thing ships with an Error Workflow. If n8n can&#x27;t reach Telegram, it posts the failure to email instead. A silently broken alerter is worse than no alerter, because now you also trust something you can&#x27;t trust.&lt;/p&gt;
&lt;h2&gt;Watching the watcher&lt;/h2&gt;
&lt;p&gt;There&#x27;s one more ring out, and it&#x27;s free. If the separate node dies, Kuma dies with it — and the witness to my EC2 just vanished without telling anyone. So one free-tier external monitor points at the status page&#x27;s heartbeat URL and pages Telegram &lt;em&gt;directly&lt;/em&gt;, bypassing n8n entirely.&lt;/p&gt;
&lt;p&gt;The principle: &lt;strong&gt;every layer alerts through a channel that doesn&#x27;t depend on the layer below it.&lt;/strong&gt; Kuma routes through n8n because Kuma and n8n failing together is the case the outer ring exists for. Two hops to Telegram for routine alerts, zero hops for the catastrophe.&lt;/p&gt;
&lt;h2&gt;Day one&lt;/h2&gt;
&lt;p&gt;The first DOWN fired before the first real incident: a &lt;code&gt;docker compose pull&lt;/code&gt; restarted Kuma for 40 seconds, the push timed out, and DOWN went into the queue. The 60-second re-check saw it back UP before Telegram ever fired — logged as a flap, nobody woken up. The grace step earned its keep before the feature ever had to be useful.&lt;/p&gt;
&lt;p&gt;The motivating case would have been different. On the day the disk filled, the external check on &lt;code&gt;network-ai.co.za&lt;/code&gt; would have caught the 502s in the first minute, with the reason attached, on my phone — while I was still re-curling it by hand.&lt;/p&gt;
&lt;p&gt;The cert tripwire is the quiet one. &lt;code&gt;certbot.timer&lt;/code&gt; runs every morning and renews at 30 days out — I checked. But a timer that runs isn&#x27;t a renewal that succeeds, and cert expiry is the failure that looks exactly like an outage to a user: a browser scary page, no server logs to grep, nothing in &lt;code&gt;error.log&lt;/code&gt;. Fourteen days before expiry, the health gate starts reporting DOWN with &lt;code&gt;cert-expires-in-13d&lt;/code&gt; whether or not certbot knows it&#x27;s broken.&lt;/p&gt;
&lt;h2&gt;What it cost&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Money:&lt;/strong&gt; $0. Uptime Kuma is free and self-hosted on a node I already pay for; the outer ring is a free tier doing exactly one job.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAM on the tight box:&lt;/strong&gt; ~0. The 908MB EC2 runs one cron line; the dashboard&#x27;s ~100MB deliberately lives somewhere else — the box only has 908MB to give in the first place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Maintenance:&lt;/strong&gt; &lt;code&gt;docker compose pull&lt;/code&gt; on the node, and rotating the push token if it ever leaks. Push URLs are bearer tokens — treat them like the credentials vaulted in the n8n post, not like a URL you paste into a README.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Lessons&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A monitor that dies with the server is a screenshot.&lt;/strong&gt; Put the witness in a different failure domain — a different box at minimum, a different provider if you can. One &lt;code&gt;docker run&lt;/code&gt; on the box you&#x27;re watching is motion, not monitoring.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Heartbeat beats ping.&lt;/strong&gt; Bake the checks that rot quietly — containers, cert days, disk — into what you push. One token, whole-system truth, and a failed push fails safe to DOWN.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grace before page.&lt;/strong&gt; Wait, re-check, then wake a human. Flapping trains people to ignore alerts, and ignored alerts are slower than any outage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The status page must survive the incident it describes.&lt;/strong&gt; Host it off the broken box, then point one free external monitor at &lt;em&gt;it&lt;/em&gt;. Every layer needs somebody watching from further out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cert expiry is a monitored service.&lt;/strong&gt; A running timer is not a successful renewal. Put a day-count tripwire in the gate so expiry shows up as DOWN two weeks early instead of as a browser warning on launch day.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alerts route through n8n with an Error Workflow.&lt;/strong&gt; Same lesson as the automation post: the alerter itself can rot, and the one thing that must never fail silently is the thing that tells you things are failing.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The site hasn&#x27;t had an outage since this went in, which is the wrong way round — monitoring earns its keep on the bad day, not the good ones. But the blind spot is closed: something that doesn&#x27;t live on this box now notices, within a minute, when the box or its services stop being what they&#x27;re supposed to be.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;You don&#x27;t own the stack because you can SSH into it. You own it when something that isn&#x27;t on it notices it&#x27;s broken before your users do — and knows exactly who to tell.&lt;/em&gt;&lt;/p&gt;]]></content:encoded>
    <category>monitoring</category>
  </item>
  <item>
    <title>n8n in Managed Services: Automating the Grind</title>
    <link>https://network-ai.co.za/blog/n8n-in-managed-services.html</link>
    <guid>https://network-ai.co.za/blog/n8n-in-managed-services.html</guid>
    <pubDate>Sat, 12 Sep 2026 00:00:00 +0000</pubDate>
    <description>How I put n8n to work on the repetitive 30% of managed-services work — rotating passwords, chasing disk space, and the ticket queue nobody wants to read.</description>
    <content:encoded><![CDATA[&lt;p&gt;Managed services runs on the same two-hour blocks every day: check the ticket queue, chase the device that dropped off, answer the &quot;is the internet down?&quot; email, look at the same eleven warnings that have been warning for a month. None of it is hard. All of it is time.&lt;/p&gt;
&lt;p&gt;So the actual skill in this job isn&#x27;t the fixing — it&#x27;s deciding &lt;em&gt;what not to do by hand&lt;/em&gt;. I&#x27;ve been using n8n for that decision for a while now, and this is the story of what it actually looks like inside a real L2 queue.&lt;/p&gt;
&lt;h2&gt;Why n8n&lt;/h2&gt;
&lt;p&gt;I could have written Python scripts cron-tabbed to the same server. I did, for years. The difference n8n makes is that it keeps the &lt;em&gt;visible process&lt;/em&gt; — trigger, condition, action — in one place the whole team can read, instead of a folder of scripts that only one person fully understands.&lt;/p&gt;
&lt;p&gt;More importantly, n8n talks to everything out of the box. The ticket API, the monitoring webhook, Teams, email, and any REST endpoint I throw at it — with retries, error handling and credential vaulting built in rather than re-built in every script.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;What runs it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Password rotations&lt;/td&gt;
&lt;td&gt;n8n schedule + device API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk-space triage&lt;/td&gt;
&lt;td&gt;n8n + monitoring webhook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ticket queue digests&lt;/td&gt;
&lt;td&gt;n8n + ITSM API + Teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device status pings&lt;/td&gt;
&lt;td&gt;n8n + SNMP / ICMP via REST&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;The password rotation nobody wanted to do&lt;/h2&gt;
&lt;p&gt;Start small. The first workflow I shipped was a rotating shared password that policy said had to change every 90 days. Three devices, one API, one boring task that was exactly the sort of thing to silently not happen for six months.&lt;/p&gt;
&lt;p&gt;The workflow is almost insultingly simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;trigger&amp;quot;: &amp;quot;cron: 0 2 1 */3 *&amp;quot;,
  &amp;quot;steps&amp;quot;: [
    { &amp;quot;action&amp;quot;: &amp;quot;generate_password&amp;quot;, &amp;quot;length&amp;quot;: 24 },
    { &amp;quot;action&amp;quot;: &amp;quot;update_device_secret&amp;quot;, &amp;quot;target&amp;quot;: &amp;quot;device-a&amp;quot; },
    { &amp;quot;action&amp;quot;: &amp;quot;notify_channel&amp;quot;, &amp;quot;channel&amp;quot;: &amp;quot;ops&amp;quot; }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generate, push, announce. Around it I wrapped an n8n &lt;em&gt;Error Workflow&lt;/em&gt; — if any step fails, a second workflow fires and posts the failure with the traceback instead of silently swallowing it. A bot that quietly fails is worse than no bot, because now you also trust the thing you can&#x27;t trust.&lt;/p&gt;
&lt;h2&gt;Disk-space triage you can actually ignore&lt;/h2&gt;
&lt;p&gt;Every monitoring platform generates the same kind of noise: 8TB volumes at 88% &quot;critical,&quot; checked twice a day, nobody actioning it because it&#x27;s twice a day and it&#x27;s always at 88%.&lt;/p&gt;
&lt;p&gt;Instead of ignoring the alert (a bad habit), I made the workflow &lt;em&gt;action it&lt;/em&gt;. When the monitoring webhook fires, n8n checks the trend: is this volume growing at a rate that matters in the next 30 days? If yes, it opens a real ticket with the trend attached. If no, it closes the alert with a note: &quot;Reviewed — 65 days until projected full, review again on &lt;date&gt;.&quot;&lt;/p&gt;
&lt;p&gt;That one workflow removed a surprisingly large chunk of the daily queue, because the queue was mostly things that &lt;em&gt;were being looked at&lt;/em&gt; but never &lt;em&gt;decided&lt;/em&gt;. n8n makes the decision; people only see the exceptions.&lt;/p&gt;
&lt;h2&gt;The ticket digest nobody asked for&lt;/h2&gt;
&lt;p&gt;The most-used workflow isn&#x27;t even the clever one. Every morning at 06:30 it pulls the open-ticket list from the ITSM API, groups by age and priority, dedupes the &quot;is the internet down&quot; repeats, and posts a one-screen summary to the ops channel.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Code node — collapse 40 tickets into 1 digest
return [{
  json: {
    summary: events.map(e =&amp;gt; ({
      id: e.ticket_id,
      age: e.age_days,
      priority: e.priority,
      label: e.title.slice(0, 120)
    })).sort((a, b) =&amp;gt; b.age - a.age).slice(0, 25)
  }
}];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The side effect I didn&#x27;t expect: people argue &lt;em&gt;about the summary format in the morning stand-up&lt;/em&gt;. That&#x27;s not a bug. Paraphrasing the queue every day made everyone aware of what a 90-day-old &quot;open but stale&quot; ticket actually looks like — and once you see five of them in one screen, closing them becomes a team decision, not a chore for whoever draws the short straw.&lt;/p&gt;
&lt;h2&gt;What I learned&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Automate the boring decisions first, not the impressive ones.&lt;/strong&gt; A password rotation protects nothing glamorous, but it&#x27;s the workflow that outlived every flashier project.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every workflow ships with an Error Workflow.&lt;/strong&gt; If it can silently rot, it will. Fail loudly into a channel people actually read.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Idempotency is the co-pilot.&lt;/strong&gt; The disk workflow re-runs on the same alert must not create five tickets. Check-then-create, or dedupe by a stable key, before you ever call the create endpoint.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The queue is mostly decisions, not work.&lt;/strong&gt; Half the tickets in managed services are already-resolved situations waiting for someone brave enough to close them. Automating the &lt;em&gt;decision&lt;/em&gt; is worth more than automating the &lt;em&gt;action&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lock down the credentials.&lt;/strong&gt; n8n&#x27;s built-in credential storage means tokens and passwords live in the vault, never in the workflow JSON. Exported workflows without secrets are also a great way to share one with a colleague.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;The cost&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Money:&lt;/strong&gt; $0. n8n runs self-hosted in Docker on the same box family I already run — though I keep it on a separate node from this site&#x27;s own stack so the two don&#x27;t fight over RAM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Maintenance:&lt;/strong&gt; it&#x27;s one more stack to keep updated. Worth it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trust:&lt;/strong&gt; the hardest line-item. It took a while before &quot;the bot already handled it&quot; was accepted as an answer in an incident call.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Automation in managed services isn&#x27;t about replacing engineers. It&#x27;s about making sure the engineers who stay are doing the work that needs judgment, and the machines handle the work that needs repetition. n8n just made that boundary visible — which was the first step to fixing it.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;If you&#x27;re in an L2 queue and your day still starts with manually typing the same four search terms into the ticket tool — that&#x27;s your second-hour project. The first hour is telling n8n to do it for you.&lt;/em&gt;&lt;/p&gt;]]></content:encoded>
    <category>automation</category>
  </item>
</channel>
</rss>