<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://zackdesign.biz/feed.xml" rel="self" type="application/atom+xml" /><link href="https://zackdesign.biz/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2026-09-07T05:46:58+00:00</updated><id>https://zackdesign.biz/feed.xml</id><title type="html">Zack Design</title><subtitle>Software engineering, web development, and digital solutions by industry experts</subtitle><author><name>Isaac Rowntree</name><email>isaac@zackdesign.biz</email></author><entry><title type="html">vesc-workbench — scripting a sealed motor controller, and the dashboard that came out of it</title><link href="https://zackdesign.biz/vesc-workbench/" rel="alternate" type="text/html" title="vesc-workbench — scripting a sealed motor controller, and the dashboard that came out of it" /><published>2026-09-06T00:00:00+00:00</published><updated>2026-09-07T00:00:00+00:00</updated><id>https://zackdesign.biz/vesc-workbench</id><content type="html" xml:base="https://zackdesign.biz/vesc-workbench/"><![CDATA[<p>Zack Design has published <strong><a href="https://github.com/isaacrowntree/vesc-workbench">vesc-workbench</a></strong> — a scripted workbench for tuning <strong>VESC</strong> motor controllers. Read, write and verify configuration, develop LispBM, and diagnose the remote and the motors, all from a Makefile over your phone’s Bluetooth bridge. No USB cable, no opening the enclosure.</p>

<p>It also contains ten dashboards for a display its manufacturer stopped supporting, which is not what I set out to build.</p>

<p><strong>MIT licensed.</strong></p>

<!-- more -->

<p><strong>Source → <a href="https://github.com/isaacrowntree/vesc-workbench">github.com/isaacrowntree/vesc-workbench</a></strong> (MIT)</p>

<h2 id="if-you-tune-a-vesc-this-is-for-you">If you tune a VESC, this is for you</h2>

<p>Everyone who runs a VESC ends up in the same loop. Change a current limit. Ride. Change it back. Change the throttle curve. Ride. Was that better, or was it a headwind? What did you actually have it set to three weeks ago, before the thing you’re now trying to undo?</p>

<p>The tooling does not help you here. VESC Tool is a good GUI, but it is a GUI: you click through tabs, you hope you typed the number into the field you meant, and when you are done there is no record of what changed. Exporting an XML backup is a manual step you take <em>instead of</em> riding, so nobody does it every time — and a backup you only take before something scary isn’t a history. And on a lot of builds — a sealed skate enclosure, a scooter deck, an ebike downtube — the USB port is behind screws, so you are doing all of this on a phone, standing in a driveway.</p>

<p>This repo is that loop, scripted:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make pull      <span class="c"># read both motor sides' configs to XML</span>
make apply     <span class="c"># write them back, then verify by reading them again</span>
</code></pre></div></div>

<p>Your settings are now text files. You can <code class="language-plaintext highlighter-rouge">git diff</code> a tuning session, review it before it goes near the motors, and revert it in one command. <code class="language-plaintext highlighter-rouge">apply</code> reads back after writing, so a setting that didn’t take is something you find out about at the bench rather than at speed — which matters, because <code class="language-plaintext highlighter-rouge">setMcconf(false)</code> accepts a write and silently discards it.</p>

<p>And it runs over Bluetooth, from your laptop, with the board sitting where it is.</p>

<h2 id="the-connection-trick-because-nobody-documents-it">The connection trick, because nobody documents it</h2>

<p>This is the part worth the post on its own, so <a href="https://github.com/isaacrowntree/vesc-workbench/blob/master/docs/connecting.md">it has its own page</a> in the repo.</p>

<p>VESC Tool ships a CLI. It looks like it should solve everything, and then it doesn’t: <code class="language-plaintext highlighter-rouge">--vescPort</code> calls <code class="language-plaintext highlighter-rouge">connectSerial()</code> and takes a serial device, full stop. Hand it an IP and it refuses. Bridge the TCP socket to a <code class="language-plaintext highlighter-rouge">socat</code> PTY and it opens the port and then never completes the handshake. If your controller isn’t reachable over USB, the documented CLI is a dead end.</p>

<p>I wrote that down as impossible. It isn’t.</p>

<p>VESC Tool also accepts <code class="language-plaintext highlighter-rouge">--loadQml</code>, and QML loaded that way runs <em>inside the application</em>, with the <code class="language-plaintext highlighter-rouge">VescIf</code> singleton in scope. <code class="language-plaintext highlighter-rouge">VescIf</code> is the whole connection and configuration API — and <code class="language-plaintext highlighter-rouge">VescIf.connectTcp()</code> is invokable from it. The phone app has a <strong>Wireless Bridge to Computer (TCP)</strong> mode sitting right there on its Start page.</p>

<p>So the path is:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>your laptop  --TCP--&gt;  phone (VESC Tool app)  --BLE--&gt;  ESC
</code></pre></div></div>

<p>The entire minimum viable version:</p>

<div class="language-qml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="nx">QtQuick</span> <span class="mf">2.7</span>

<span class="kt">Item</span> <span class="p">{</span>
    <span class="nl">id</span><span class="p">:</span> <span class="kd">root</span>
    <span class="kd">property</span> <span class="kt">int</span> <span class="nl">ticks</span><span class="p">:</span> <span class="mi">0</span>

    <span class="nl">Component.onCompleted</span><span class="p">:</span> <span class="nx">VescIf</span><span class="p">.</span><span class="nf">connectTcp</span><span class="p">(</span><span class="dl">"</span><span class="s2">192.168.1.100</span><span class="dl">"</span><span class="p">,</span> <span class="mi">65102</span><span class="p">)</span>

    <span class="kt">Timer</span> <span class="p">{</span>
        <span class="nl">interval</span><span class="p">:</span> <span class="mi">500</span><span class="p">;</span> <span class="nl">running</span><span class="p">:</span> <span class="kc">true</span><span class="p">;</span> <span class="nl">repeat</span><span class="p">:</span> <span class="kc">true</span>
        <span class="nl">onTriggered</span><span class="p">:</span> <span class="p">{</span>
            <span class="nx">root</span><span class="p">.</span><span class="nx">ticks</span><span class="o">++</span>
            <span class="k">if </span><span class="p">(</span><span class="nx">root</span><span class="p">.</span><span class="nx">ticks</span> <span class="o">&gt;</span> <span class="mi">60</span><span class="p">)</span> <span class="p">{</span> <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">timeout</span><span class="dl">"</span><span class="p">);</span> <span class="nx">Qt</span><span class="p">.</span><span class="nf">quit</span><span class="p">()</span> <span class="p">}</span>
            <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">VescIf</span><span class="p">.</span><span class="nf">isPortConnected</span><span class="p">())</span> <span class="k">return</span>

            <span class="c1">// Firmware params arrive AFTER the socket connects. Until they do,</span>
            <span class="c1">// getFirmwareNow() returns "x.x" and every config read is garbage.</span>
            <span class="kd">var</span> <span class="nx">fw</span> <span class="o">=</span> <span class="nx">VescIf</span><span class="p">.</span><span class="nf">getFirmwareNow</span><span class="p">()</span>
            <span class="k">if </span><span class="p">(</span><span class="nx">fw</span><span class="p">.</span><span class="nf">indexOf</span><span class="p">(</span><span class="dl">"</span><span class="s2">x.x</span><span class="dl">"</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span>

            <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">connected, fw </span><span class="dl">"</span> <span class="o">+</span> <span class="nx">fw</span><span class="p">)</span>
            <span class="nx">VescIf</span><span class="p">.</span><span class="nf">disconnectPort</span><span class="p">()</span>
            <span class="nx">Qt</span><span class="p">.</span><span class="nf">quit</span><span class="p">()</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s2">"/Applications/VESC Tool.app/Contents/MacOS/VESC Tool"</span> <span class="nt">--offscreen</span> <span class="nt">--loadQml</span> connect.qml
</code></pre></div></div>

<p>That’s it. <code class="language-plaintext highlighter-rouge">--offscreen</code> keeps the GUI away, <code class="language-plaintext highlighter-rouge">console.log</code> goes to stdout, and from there <code class="language-plaintext highlighter-rouge">VescIf.mcConfig()</code>, <code class="language-plaintext highlighter-rouge">VescIf.appConfig()</code> and <code class="language-plaintext highlighter-rouge">VescIf.commands()</code> are all yours. You do not need the rest of my repo to use this — take the file.</p>

<p>That comment about <code class="language-plaintext highlighter-rouge">"x.x"</code> is not decoration: the socket connects several seconds before the firmware parameters arrive, and a config read in that window returns defaults that look exactly like a controller which has wiped itself.</p>

<h2 id="diagnostics-that-answer-the-actual-question">Diagnostics that answer the actual question</h2>

<p>Half of tuning is not tuning, it’s working out what is wrong. A GUI shows you a number; it rarely tells you <em>why the number is that</em>.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make ppm-watch    <span class="c"># live remote readout — prints only on change</span>
make ppm-cal      <span class="c"># guided calibration: neutral, full throttle, full brake</span>
make probe        <span class="c"># connect, report firmware and LispBM state</span>
make check        <span class="c"># is the bridge up? is desktop VESC Tool holding it?</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ppm-watch</code> distinguishes <strong>the remote is not transmitting</strong> from <strong>the decoder is not running</strong>. In VESC Tool those look identical — a still bar — and they have completely different fixes. <code class="language-plaintext highlighter-rouge">make check</code> does the same thing for the connection: it tells you which failure you have in a second, instead of leaving you to interpret a two-minute timeout.</p>

<p>And when you’re working on a board that is powered up:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make motors-off   <span class="c"># kill motor output, no config write, display stays live</span>
make motors-on
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">app-disable-output</code> rather than a configuration change, so nothing needs undoing afterwards and nothing gets left in a weird state if you walk away.</p>

<h2 id="the-findings-are-the-other-half-of-the-repo">The findings are the other half of the repo</h2>

<p><code class="language-plaintext highlighter-rouge">docs/findings.md</code> is the document I wanted to find and could not. The pattern in all of them is the same: the ESC does something reasonable, reports it accurately, and the accurate report points at the wrong thing.</p>

<table>
  <thead>
    <tr>
      <th>What you see</th>
      <th>What is actually happening</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Throttle dead after a LispBM script ran, and a reboot doesn’t fix it</td>
      <td><code class="language-plaintext highlighter-rouge">uart-start</code> permanently flashes <code class="language-plaintext highlighter-rouge">app_to_use = APP_NONE</code></td>
    </tr>
    <tr>
      <td>PPM values go in and come back wrong, so you suspect the remote</td>
      <td>The sub-config silently refuses writes while <code class="language-plaintext highlighter-rouge">ctrl_type</code> is 0</td>
    </tr>
    <tr>
      <td>“did you forget to upload the code” — which reads like your mistake</td>
      <td><code class="language-plaintext highlighter-rouge">lispWriteCode()</code> doesn’t land code; <code class="language-plaintext highlighter-rouge">CodeLoader.lispUploadFromPath</code> does</td>
    </tr>
    <tr>
      <td>A config that looks corrupted</td>
      <td>A read taken before the ESC finished booting</td>
    </tr>
  </tbody>
</table>

<p>There’s a correction in there too. The esk8 forums will tell you VESC’s built-in traction control interferes with braking and is dangerous. I enabled it, then read <code class="language-plaintext highlighter-rouge">app_ppm.c</code> to understand the failure mode: it lives entirely in the non-brake branch, braking never reaches the traction-control code, and it self-disengages on any fault. The warning is real for some other control paths; for PPM on current firmware it’s repeated folklore. Documented with the file and the branch, so you can check my reasoning rather than trusting either of us.</p>

<h2 id="where-it-came-from-a-display-that-refused-to-grow-up">Where it came from: a display that refused to grow up</h2>

<p>All of this exists because of a much smaller problem.</p>

<p>I have a LaCroix Nazaré — an electric skateboard with a FOCBOX Unity sealed in the enclosure, which I ride on grass at a golf course, a surface demanding enough that the tuning genuinely matters. I wanted it on firmware 7 for the FOC improvements and LispBM. The DAVEGA X display bolted to the deck refused to run on it:</p>

<blockquote>
  <p>Unsupported VESC FW: 7.01</p>

  <p>Supported VESC FW versions: 3.48 - 6.x</p>
</blockquote>

<p>DAVEGA is discontinued. Everyone downgrades.</p>

<p>I checked whether that was really true rather than assuming it. The vendor’s update index still lists a <strong>v5.07rc3, dated 2025-03-11</strong> — newer than the v5.06 the public changelog stops at, released after the shop closed and never announced. It carries the same constant as every version before it. No firmware DAVEGA ever shipped accepts VESC 7.</p>

<p>But <strong>the telemetry protocol did not change</strong>. <code class="language-plaintext highlighter-rouge">COMM_GET_VALUES</code> returns the same twenty-five fields, same order, same scaling, in 6.00 and in 7.x. The display isn’t failing to parse anything — it reads two version bytes, sees a 7, and declines to have the conversation.</p>

<p>VESC firmware 6 embeds <strong>LispBM</strong>, and from 6.06 it exposes the firmware’s own command decoder to it as <code class="language-plaintext highlighter-rouge">cmds-proc</code>. So a script on the ESC can take the display’s UART line, hand every packet to the real handler, and rewrite the reply on the way out:</p>

<div class="language-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nb">defun</span> <span class="nv">fixfw</span> <span class="p">(</span><span class="nv">d</span><span class="p">)</span> <span class="nv">{</span>
    <span class="p">(</span><span class="nv">var</span> <span class="nv">n</span> <span class="p">(</span><span class="nv">bufget-u8</span> <span class="nv">d</span> <span class="mi">1</span><span class="p">))</span>
    <span class="p">(</span><span class="k">if</span> <span class="p">(</span><span class="nb">and</span> <span class="p">(</span><span class="nb">=</span> <span class="p">(</span><span class="nv">bufget-u8</span> <span class="nv">d</span> <span class="mi">0</span><span class="p">)</span> <span class="mi">2</span><span class="p">)</span> <span class="p">(</span><span class="nb">=</span> <span class="p">(</span><span class="nv">bufget-u8</span> <span class="nv">d</span> <span class="mi">2</span><span class="p">)</span> <span class="mi">0</span><span class="p">))</span> <span class="nv">{</span>
        <span class="p">(</span><span class="nv">bufset-u8</span> <span class="nv">d</span> <span class="mi">3</span> <span class="mi">6</span><span class="p">)</span>                    <span class="c1">; FW_VERSION_MAJOR -&gt; 6</span>
        <span class="p">(</span><span class="nv">bufset-u8</span> <span class="nv">d</span> <span class="mi">4</span> <span class="mi">0</span><span class="p">)</span>                    <span class="c1">; FW_VERSION_MINOR -&gt; 0</span>
        <span class="p">(</span><span class="nv">bufcpy</span> <span class="nv">p</span> <span class="mi">0</span> <span class="nv">d</span> <span class="mi">2</span> <span class="nv">n</span><span class="p">)</span>                   <span class="c1">; crc16 always starts at index 0</span>
        <span class="p">(</span><span class="nv">var</span> <span class="nv">c</span> <span class="p">(</span><span class="nv">crc16</span> <span class="nv">p</span> <span class="nv">n</span><span class="p">))</span>
        <span class="p">(</span><span class="nv">bufset-u8</span> <span class="nv">d</span> <span class="p">(</span><span class="nb">+</span> <span class="mi">2</span> <span class="nv">n</span><span class="p">)</span> <span class="p">(</span><span class="nv">shr</span> <span class="nv">c</span> <span class="mi">8</span><span class="p">))</span>
        <span class="p">(</span><span class="nv">bufset-u8</span> <span class="nv">d</span> <span class="p">(</span><span class="nb">+</span> <span class="mi">3</span> <span class="nv">n</span><span class="p">)</span> <span class="p">(</span><span class="nv">bitwise-and</span> <span class="nv">c</span> <span class="mi">255</span><span class="p">))</span><span class="nv">}</span><span class="p">)</span><span class="nv">}</span><span class="p">)</span>
</code></pre></div></div>

<p>Twenty-seven lines for the rewrite, 178 for the whole shim once you count the UART reader and its debug counters. Everything else passes through untouched. The display sees a 6.00 controller; it’s talking to a 7.00 controller; both are telling the truth about the only thing that matters.</p>

<p>You shouldn’t take my word for the payload being unchanged, so <code class="language-plaintext highlighter-rouge">tests/protocol-diff.sh</code> checks out both firmware versions from upstream and diffs the serialisation on every CI run. If Vedder ever changes the layout, the test goes red and the shim is wrong in a way you find out about immediately rather than at 40 km/h.</p>

<p>If you have a DAVEGA and want to keep its own firmware, that shim is the whole answer and you can stop reading here.</p>

<h2 id="writing-lispbm-without-bricking-your-throttle">Writing LispBM without bricking your throttle</h2>

<p>If you’re doing anything custom on a VESC, LispBM is where it happens, and the repo treats it as a real development environment:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>make upload-lisp <span class="nv">LISP</span><span class="o">=</span>path/to/script.lisp   <span class="c"># upload and run</span>
make lisp-stats                             <span class="c"># heap, CPU, globals</span>
make lisp-stop / lisp-erase                 <span class="c"># stop, or back to stock</span>
make test-lisp                              <span class="c"># run it in the real interpreter</span>
</code></pre></div></div>

<p>That last one matters more than it sounds. Scripts are tested by running them in the <strong>upstream LispBM REPL</strong> in Docker with stubs for the VESC extensions — not by transcribing them into Python and testing the transcription. LispBM symbols are case-insensitive, <code class="language-plaintext highlighter-rouge">t</code> is a special symbol that never resolves from the environment (so <code class="language-plaintext highlighter-rouge">(var t ...)</code> silently kills the context that uses it), and <code class="language-plaintext highlighter-rouge">uart-read</code>’s timeout argument is in seconds and isn’t the argument you’d guess. Running the actual interpreter catches all three. A reimplementation catches none of them.</p>

<p>And <code class="language-plaintext highlighter-rouge">make lisp-erase</code> is always one command from stock behaviour, which is the thing that makes experimenting on a board you ride tolerable.</p>

<h2 id="then-the-display-stopped-being-a-black-box">Then the display stopped being a black box</h2>

<p>The shim treats the DAVEGA as something to lie to. That stopped being true once I went looking properly.</p>

<p>Its firmware is not open source and the shop closed in 2024, but the vendor’s own installer still names its endpoints, and they still resolve. More usefully: the X is an <strong>ESP32 running MicroPython</strong>, and it will give you a REPL. Hold up and down while it boots and it raises its own access point.</p>

<p>From there its filesystem is right in front of you. Settings are a plain <code class="language-plaintext highlighter-rouge">/data/config.json</code> — which is how I found the display had been configured for 175 mm wheels on 72/16 gearing when the board runs 200 mm on 84/20. It had been under-reading my speed by 18%, silently, for as long as I had owned it. One command fixed it.</p>

<p>It also means the screen is programmable. The firmware runs a user <code class="language-plaintext highlighter-rouge">start.py</code> <em>before</em> the stock app starts, so a replacement dashboard is not a firmware build — it is one file you can delete. Hold UP at boot and it steps aside.</p>

<p>At which point the shim stops being necessary at all. A dashboard I wrote has no version gate to fail, so it reads firmware 7 directly, and the LispBM context on the ESC is free for something better than lying about a version byte — it runs a flight recorder now.</p>

<h2 id="so-the-display-got-ten-dashboards">So the display got ten dashboards</h2>

<p><img src="/images/blog/davega-themes.png" alt="Ten dashboards for the DAVEGA X, each a different layout, rendered at true 240x320 device size" /></p>

<p>Ten themes, each with <strong>its own layout</strong> rather than its own colours: a full analogue tachometer, hexagonal shards on a diagonal split, three hairline arcs, one enormous thin numeral, concentric rings with a single red hand, a shift-light rail you read peripherally, a power-flow meter that treats current as more important than speed. The default takes the best idea from each and throws the rest away.</p>

<p>Those are not mockups. Every picture in this post is the real screen code run through a host-side stand-in for the panel, rendering the pixels it actually produced.</p>

<p>The interesting part is not the pictures. It is that <strong>the panel cannot draw a curve.</strong></p>

<p>The display driver offers <code class="language-plaintext highlighter-rouge">fill_rectangle</code>, <code class="language-plaintext highlighter-rouge">pixel</code> and <code class="language-plaintext highlighter-rouge">writeblock</code>. There is no line, no circle, no polygon. And measured on the device, the costs are not what you would guess:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>measured</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fill_rectangle</code></td>
      <td><strong>2.9 ms</strong> — independent of size</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pixel</code></td>
      <td><strong>2.29 ms</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">writeblock</code>, 240×40</td>
      <td><strong>12 ms</strong> for 9,600 pixels</td>
    </tr>
    <tr>
      <td>free heap</td>
      <td><strong>98 kB</strong> — a 200×100 RGB565 buffer fails to allocate</td>
    </tr>
  </tbody>
</table>

<p>A draw call costs the same whether it covers nine pixels or nine thousand. So the obvious way to draw an arc — one thin rectangle per column, the way you would rasterise it — costs <strong>481 ms for a 96 px radius</strong>. That is four times the budget for an entire frame, to draw one gauge.</p>

<p>The way through is the third primitive. Compose the curve into a memory buffer and push it in a single transfer — about 6 ms for a 240×20 strip, and the maths in between is free because it never touches the bus. There isn’t enough RAM to hold the whole picture, so it goes in horizontal strips with one buffer reused down the screen, and the drawing code works in absolute screen coordinates while each strip quietly discards what falls outside it.</p>

<p>That is the entire trick, and it is what makes a tachometer possible on a panel with no line primitive. The dial face, its bezel and its twenty-one tick marks are composed once as furniture; the sweep is composed the same way into a band that covers only the dial.</p>

<p>I did try the cleverer version first — repaint only the wedge between the old angle and the new. It is cheaper, and it is wrong: an arc drawn in three pieces lands on different pixels from the same arc drawn in one, because each piece quantises its own start angle. A partial renderer that disagrees with a full repaint is worse than one that costs a few more milliseconds, so the arcs repaint whole. Everything that is a straight line still repaints only its tip.</p>

<p>Steady-state cost across all ten: <strong>23 to 88 ms a frame</strong>, against 5 Hz telemetry.</p>

<h2 id="the-harness-is-the-reason-any-of-it-works">The harness is the reason any of it works</h2>

<p>A 2.8″ screen bolted to a deck is a terrible place to iterate a design. So before the first dashboard there was a host-side stand-in for the ILI9341 that records every draw call, rasterises to PNG, and models the panel’s cost. Screens are pure functions of a telemetry frame, so the same code runs on the device and in CI, and <strong>642 checks</strong> run with no hardware attached.</p>

<p>Four things it catches that looking at the screen cannot:</p>

<ul>
  <li><strong>Anything that leaves the 240×320 frame</strong> fails the build, rather than being clipped where you might not notice.</li>
  <li><strong>Golden images</strong> for every frame in the envelope — standstill, full throttle, hard regen, thermal derate, battery empty, battery full, a fault at speed. Those frames are <em>generated from the board’s verified configuration</em>, so the extremes a real ride rarely produces are covered on purpose.</li>
  <li><strong>Partial redraw against a full repaint</strong>, across all <strong>810</strong> transitions between envelope frames and themes. Partial redraw’s failure mode is stale pixels — a value that got shorter, a gauge that receded — and the two paths must agree exactly, pixel for pixel.</li>
  <li><strong>Chrome labels against live regions.</strong> A label is drawn once; a region repaints whenever its value changes; the panel’s font is opaque. So a label sitting inside a region’s box is erased the first time that value moves and never comes back. It looks right at a standstill and wrong thirty seconds into a ride.</li>
</ul>

<p>That last one is the test I’m most pleased with, because the harness was flattering the design until I fixed it. It drew glyphs without their backgrounds, while the real font fills the whole character cell — so labels that punched holes through their own gauges looked perfect in every render. Modelling the panel honestly made four themes fail immediately.</p>

<p>Every theme goes through all of it, and a parity test asserts each of the 64 declared colours actually appears in the rendered output, so a palette nobody has looked at cannot ship.</p>

<h2 id="and-then-the-same-code-in-the-interpreter-that-runs-it">And then the same code, in the interpreter that runs it</h2>

<p>All of that is CPython, which is fast and convenient and does not tell you whether the code will <em>start</em> on the board. So there is a second suite that builds <strong>MicroPython 1.14</strong> — the version the DAVEGA reports — with <code class="language-plaintext highlighter-rouge">framebuf</code> compiled in, and drives the real modules through a whole ride: boot, telemetry replayed from frames recorded off the Unity, every screen, the buttons through their real 50 ms debounce, the menu, a controller that stops answering, and the recovery. Same argument as testing LispBM in the upstream REPL: test the code in the interpreter that will execute it, not in a host language that resembles it.</p>

<p>It earned its keep on the first run. MicroPython stores an RGB565 pixel with a native 16-bit write, so on a little-endian MCU the low byte lands first — and the ILI9341 wants the high byte first, with the transfer streaming the buffer untouched. <strong>Every curve would have reached the panel with its colours reversed.</strong> Red drawing blue. It could not show up off-device, because the pure-Python stand-in writes big-endian directly: the harness and the hardware disagreed and only the hardware was right. The two now render the same drawing side by side and compare byte for byte.</p>

<p>The other thing it watches is allocation, because 98 kB of heap and a two-hour ride is a bad combination for anything that leaks. It measures two consecutive 400-frame windows, because one window cannot tell a leak from a warm-up — caches fill and tweens settle in the first, and none of it repeats. Absolute byte counts are reported rather than asserted: this is the unix port on a 64-bit host, where every reference is twice the width, so only the drift carries.</p>

<p>Which is exactly the limit that bit me later. The board would not start one morning, stuck on the stock firmware’s “initializing” — a <code class="language-plaintext highlighter-rouge">MemoryError</code> asking for ten kilobytes to draw the first curve. Everything passed on the host, where the heap is twenty times larger. The fix was to claim that buffer during boot while the heap is still whole, take the largest band available rather than one fixed size, and have the loop fall back to the lightest layout instead of handing the screen back. A dead screen on a deck is worse than the wrong colours.</p>

<h2 id="where-its-at">Where it’s at</h2>

<p>Telemetry is live and the board has been out on it: a DAVEGA X running a dashboard I wrote, reading a FOCBOX Unity on firmware 7.00 directly, no version spoofing in the path. A Unity is two controllers in one case, and the standard reply only carries whichever one answered — its own temperature, its own tachometer — so the local ESC is asked over the wire and the second is asked through it over CAN, and the two are combined the way DAVEGA’s own Unity code does it: pack current and energy summed, per-motor figures averaged, distance counted once. Temperature is the one place I diverge and take the hotter of the two, because an average hides the controller that is about to derate behind the one that is fine.</p>

<p>The board is tuned for grass, 80 A a side, and traction control is on and confirmed after a hard run round a golf course. Themes and the dashboard both install over WiFi in one command, as precompiled bytecode — MicroPython compiles a <code class="language-plaintext highlighter-rouge">.py</code> every time it imports it, and on this ESP32 that compile was most of the wait between switching the board on and seeing a number.</p>

<p>And it has been ridden. Speed, current, charge and range on the glass, both controllers read, at 40 km/h on grass — which is the only test that was ever going to settle it, and the one the whole harness exists to earn the right to run.</p>

<p>Hardware coverage is honest — FOCBOX Unity, one board, one display — but the connection layer and the config workflow aren’t Unity-specific at all. <code class="language-plaintext highlighter-rouge">vesc/profiles/</code> holds one file per known-good setup, and deliberately holds <em>connection details only</em>: not current limits, not gearing. Copying a stranger’s motor tuning is how packs and motors get damaged. Run the detection wizard, then use this to keep track of what you changed.</p>

<p>If you have a VESC and a scripting habit, start with <a href="https://github.com/isaacrowntree/vesc-workbench/blob/master/docs/connecting.md">docs/connecting.md</a>. Even if you use nothing else, having your board’s configuration in git is worth the twenty minutes.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="lispbm" /><category term="lisp" /><category term="python" /><category term="micropython" /><category term="qml" /><category term="makefile" /><category term="vesc" /><category term="embedded" /><category term="firmware" /><category term="reverse-engineering" /><category term="ui-design" /><category term="electric-skateboard" /><summary type="html"><![CDATA[An open-source workbench for tuning VESC motor controllers over your phone's Bluetooth bridge with no USB — and, once the discontinued DAVEGA X display turned out to be a scriptable ESP32, ten replacement dashboards for it, tested without hardware.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/vesc-workbench.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/vesc-workbench.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">clawdwatch — synthetic monitoring for Cloudflare Workers, with an inbox an AI agent can read</title><link href="https://zackdesign.biz/clawdwatch/" rel="alternate" type="text/html" title="clawdwatch — synthetic monitoring for Cloudflare Workers, with an inbox an AI agent can read" /><published>2026-08-21T00:00:00+00:00</published><updated>2026-08-21T00:00:00+00:00</updated><id>https://zackdesign.biz/clawdwatch</id><content type="html" xml:base="https://zackdesign.biz/clawdwatch/"><![CDATA[<p><a href="https://github.com/triptechtravel/clawdwatch"><code class="language-plaintext highlighter-rouge">clawdwatch</code></a> is a <a href="https://github.com/triptechtravel">Triptech Travel</a> open-source project — authored and released by Isaac Rowntree in his Triptech engineering capacity, and cross-posted here on the Zack Design blog, alongside <a href="/clickup-cli/">clickup-cli</a> and <a href="/slackbuzz-cli/">slackbuzz-cli</a>. It is synthetic monitoring that runs entirely inside a single Cloudflare Worker: it checks your endpoints on a cron, decides — deterministically — when something is genuinely broken, and hands that off to Slack, a signed webhook, an RPC service binding, or an AI agent. <strong>MIT licensed.</strong></p>

<!-- more -->

<p><strong>Source → <a href="https://github.com/triptechtravel/clawdwatch">github.com/triptechtravel/clawdwatch</a></strong> · <strong>Docs → <a href="https://triptechtravel.github.io/clawdwatch/">triptechtravel.github.io/clawdwatch</a></strong></p>

<h2 id="the-line-this-draws">The line this draws</h2>

<p>There is an obvious, tempting version of an AI-era monitoring tool where a model looks at the response and decides whether you have a problem. clawdwatch deliberately does not do that. Detection is a threshold, a state machine, and a maintenance window — code you can read in an afternoon and reason about at 3am. What the AI gets is the <em>other</em> half of the job: the part after “this is broken,” where somebody has to work out why.</p>

<p>That split is the whole design. Everything below is either “make the detection boring and trustworthy” or “make the handoff useful.”</p>

<h2 id="detection-is-a-state-machine">Detection is a state machine</h2>

<p>One check, one result, one transition — pure, with the clock injected as a parameter rather than read from <code class="language-plaintext highlighter-rouge">Date.now()</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>unknown   → healthy     first success                      — silent
healthy   → degraded    failure, threshold not yet met     — silent
degraded  → unhealthy   consecutive failures &gt;= threshold  — OPENED
unhealthy → unhealthy   still failing, reminder due        — REMINDER
unhealthy → healthy     first success again                — RECOVERED
degraded  → healthy     recovered before opening           — silent
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">unhealthy → unhealthy</code> edge is the one that matters and the one the previous generation of this tool could not express at all: it returned nothing forever, which meant a multi-day outage alerted exactly once, on day one, and then went quiet while still being down. Reminders are only sayable if your state machine has a name for “still broken, and it has been a while.”</p>

<p>Alerts are batched per run, so a ten-endpoint outage is one notification rather than ten.</p>

<h2 id="one-storage-system">One storage system</h2>

<p>D1. That is the entire persistence story — checks, hot state, results, deliveries, incidents.</p>

<p>The version before this one split hot state into an R2 JSON blob and history into an Analytics Engine dataset that nothing ever read. Two storage systems, two consistency stories, and a whole class of “which one is right?” bugs, in exchange for a dataset nobody queried. Collapsing it into D1 removed more code than it added, and the integration suite now applies the shipped migration and runs in workerd — so the SQL is genuinely exercised rather than mocked.</p>

<h2 id="public-code-private-config">Public code, private config</h2>

<p>Checks live in the database and are editable through the UI. That is only safe if a secret can never land in a row, so:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"headers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"X-Api-Key"</span><span class="p">:</span><span class="w"> </span><span class="s2">"${MY_API_KEY}"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The reference is what is stored. Substitution happens at exactly one point — building the outbound request — and every path <em>leaving</em> the system (API responses, alert payloads, logs, config exports) goes through redaction first. Writing a check that contains a literal secret value is rejected with a 400, and a property test asserts that no resolved value appears in any outbound representation.</p>

<p>That single guard is what makes a UI-editable, database-backed monitor safe to open-source at all.</p>

<p>For a token that belongs to a whole domain rather than one check — a WAF bypass, say — there are <code class="language-plaintext highlighter-rouge">headerRules</code> keyed by host pattern.</p>

<h2 id="keeping-the-thing-that-explains-the-outage">Keeping the thing that explains the outage</h2>

<p>By default, response bodies are read to evaluate assertions and then discarded; what gets stored is the assertion failure message, truncated to 256 characters. A monitored endpoint that returns personal data does not leak it into the monitoring database.</p>

<p>But “expected 200, got 500” throws away the one thing that usually explains the outage. The motivating case was a health endpoint whose 500 body named the failing dependency by name — and the alert carried none of it. So a check can opt in:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"api-health"</span><span class="p">,</span><span class="w"> </span><span class="nl">"captureBodyOnFailure"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The excerpt is capped at 512 characters, taken only from textual content types, run through the same secret scrubber as everything else <em>before</em> truncation (so a secret straddling the cut is masked, not half-printed), never taken from a passing check, and deliberately never posted to Slack. It reaches the webhook notifier and the dashboard — both of which are already trusted with the monitoring database. Slack is not.</p>

<h2 id="handing-an-incident-to-an-agent">Handing an incident to an agent</h2>

<p>An agent inbox is just a URL, so <code class="language-plaintext highlighter-rouge">webhook()</code> is the whole integration. Nothing is installed into the agent:</p>

<ul>
  <li>Every alert carries a <code class="language-plaintext highlighter-rouge">links</code> object — incident, ack, annotate, run-now — as <strong>short-lived signed URLs</strong>. The receiving agent can act on the alert it was handed without holding any standing credential. If the alert leaks, what leaked expires.</li>
  <li><code class="language-plaintext highlighter-rouge">GET /api/agent.md</code> describes the full API, generated from the route table so it cannot drift. Point an agent at that URL and the setup is done.</li>
  <li>An agent that has triaged something writes its findings back with <code class="language-plaintext highlighter-rouge">POST /api/incidents/:id/annotate</code>, and the note appears on the incident in the dashboard next to the human comments.</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">agent.md</code> route replaced an earlier idea: shipping a skill file for agents to install. A static file that duplicates a live API drifts, and in practice never gets installed — the previous version’s skill claimed 90-day retention for a system that kept 48 hours, and had never actually been copied into the container it was meant for. Generating the document from the route table, with a test asserting the two never diverge, is the version that stays true.</p>

<p>There is a worked receiver: <a href="https://github.com/triptechtravel/thinkbot">thinkbot</a>, an ops agent that takes clawdwatch alerts and correlates them against GitHub, Datadog, Sentry and Rollbar to report what changed.</p>

<h2 id="when-the-receiver-is-a-worker-skip-the-webhook">When the receiver is a Worker, skip the webhook</h2>

<p>If the thing receiving alerts lives on the same Cloudflare account, a service binding beats HTTP: the platform authenticates the call, so there is no shared HMAC secret to distribute or rotate, and no public inbox to defend.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">notifiers</span><span class="p">:</span> <span class="p">[</span><span class="nf">rpc</span><span class="p">({</span> <span class="na">binding</span><span class="p">:</span> <span class="p">(</span><span class="nx">env</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">env</span><span class="p">.</span><span class="nx">AGENT</span> <span class="p">})]</span>
</code></pre></div></div>

<p>One sharp edge worth stating out loud, because it is the kind of thing that quietly becomes a vulnerability: an RPC call carries <strong>no signature</strong>. Authenticity comes from the binding itself. Keep signature verification in your HTTP handler, and do not let shared downstream code assume it ran.</p>

<p>Service bindings are same-account only, so <code class="language-plaintext highlighter-rouge">webhook()</code> with <code class="language-plaintext highlighter-rouge">hmac()</code> remains the option for everyone else.</p>

<h2 id="a-versioned-payload-so-a-release-is-not-an-outage">A versioned payload, so a release is not an outage</h2>

<p>Every alert carries <code class="language-plaintext highlighter-rouge">schemaVersion</code>, exported as <code class="language-plaintext highlighter-rouge">ALERT_SCHEMA_VERSION</code>, and the contract is narrow enough to be useful:</p>

<ul>
  <li>adding an <strong>optional</strong> field does not bump it — <code class="language-plaintext highlighter-rouge">bodySnippet</code> was added exactly this way;</li>
  <li>removing or renaming a field, or changing its meaning or type, does.</li>
</ul>

<p>Receivers should ignore unknown fields and must not hard-fail on a version higher than they know. A receiver that rejects unknown versions turns every clawdwatch release into a monitoring outage, which is a genuinely embarrassing way to lose visibility.</p>

<h2 id="auth-and-the-thing-it-refuses-to-do">Auth, and the thing it refuses to do</h2>

<p>Mount it behind <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/">Cloudflare Access</a>. Reads are open by default; writes always require a principal — a signed-in person, a service token, or a capability link.</p>

<p>There is no query-parameter API key, on purpose. URLs leak into logs, analytics, and referrer headers, and a shared static secret has no identity, no expiry, and no revocation. Arriving through Access is also not treated as evidence in itself: JWTs are verified against the team’s JWKS, the expected <code class="language-plaintext highlighter-rouge">aud</code>, the issuer, and the clock — because anyone who learns the Worker’s direct route bypasses the Access edge entirely.</p>

<p>No identity is stored. The JWT is verified for the authorization decision and discarded; there are no email, IP, or user-agent columns.</p>

<h2 id="a-dashboard-you-will-actually-read">A dashboard you will actually read</h2>

<p>One mark per check run. A five-minute cron looks like five-minute samples, not a smoothed line — because the smoothing is exactly where a two-run blip goes to hide. Every delivery is recorded too, so the dashboard can answer the question monitoring tools are worst at: <em>did the last alert actually arrive?</em></p>

<h2 id="getting-it-running">Getting it running</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm create cloudflare@latest my-monitor <span class="nt">--</span> <span class="se">\</span>
  <span class="nt">--template</span> clawdwatch/clawdwatch/examples/worker
<span class="nb">cd </span>my-monitor

wrangler d1 create clawdwatch          <span class="c"># paste the id into wrangler.jsonc</span>
npm run migrate
wrangler secret put SLACK_WEBHOOK_URL   <span class="c"># optional, and all Slack needs</span>
npm run deploy
</code></pre></div></div>

<p>Or use it as a library, if you would rather it be a route inside a Worker you already run:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">createMonitor</span><span class="p">,</span> <span class="nx">slack</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">clawdwatch</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">monitor</span> <span class="o">=</span> <span class="nx">createMonitor</span><span class="o">&lt;</span><span class="nx">Env</span><span class="o">&gt;</span><span class="p">({</span>
  <span class="na">d1</span><span class="p">:</span> <span class="p">(</span><span class="nx">env</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">env</span><span class="p">.</span><span class="nx">MONITORING_DB</span><span class="p">,</span>
  <span class="na">secrets</span><span class="p">:</span> <span class="p">(</span><span class="nx">env</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="na">SLACK_WEBHOOK_URL</span><span class="p">:</span> <span class="nx">env</span><span class="p">.</span><span class="nx">SLACK_WEBHOOK_URL</span> <span class="p">}),</span>
  <span class="na">notifiers</span><span class="p">:</span> <span class="p">[</span><span class="nf">slack</span><span class="p">({</span> <span class="na">webhook</span><span class="p">:</span> <span class="dl">'</span><span class="s1">${SLACK_WEBHOOK_URL}</span><span class="dl">'</span> <span class="p">})],</span>
<span class="p">});</span>

<span class="k">export</span> <span class="k">default</span> <span class="p">{</span> <span class="na">fetch</span><span class="p">:</span> <span class="nx">monitor</span><span class="p">.</span><span class="nx">fetch</span><span class="p">,</span> <span class="na">scheduled</span><span class="p">:</span> <span class="nx">monitor</span><span class="p">.</span><span class="nx">scheduled</span> <span class="p">};</span>
</code></pre></div></div>

<p>Assertions cover status code, headers, body, response time, and <code class="language-plaintext highlighter-rouge">jsonPath</code> — the last of which is what turns a health endpoint that returns <code class="language-plaintext highlighter-rouge">{"db":"ok","queue":"degraded"}</code> into an actual signal rather than a 200.</p>

<h2 id="why-it-exists">Why it exists</h2>

<p>Triptech runs production APIs on Cloudflare Workers, and the monitoring options are roughly: a SaaS that bills per check and knows nothing about your account, or a Worker you write yourself and then never quite finish. clawdwatch is the finished version of the second one — small enough to read, deterministic where it needs to be, and built so the interesting half of an incident can be handed to something that will actually go and look.</p>

<p><a href="https://github.com/triptechtravel/clawdwatch">Read the source</a>, or start at <a href="https://triptechtravel.github.io/clawdwatch/guide/getting-started">getting started</a>.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="cloudflare" /><category term="workers" /><category term="d1" /><category term="monitoring" /><category term="typescript" /><category term="ai-agents" /><category term="security" /><category term="open-source" /><summary type="html"><![CDATA[An open-source uptime monitor that runs entirely inside a Cloudflare Worker: deterministic detection in a state machine, D1 as the only storage, secrets that never reach the database, and alerts that carry signed action links an agent can use without holding a credential.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/clawdwatch.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/clawdwatch.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Four Australian client sites, rebuilt for 2026 — and shipped behind a preview on their own live domains</title><link href="https://zackdesign.biz/client-sites-rebuilt/" rel="alternate" type="text/html" title="Four Australian client sites, rebuilt for 2026 — and shipped behind a preview on their own live domains" /><published>2026-08-21T00:00:00+00:00</published><updated>2026-08-21T00:00:00+00:00</updated><id>https://zackdesign.biz/client-sites-rebuilt</id><content type="html" xml:base="https://zackdesign.biz/client-sites-rebuilt/"><![CDATA[<p>Zack Design has rebuilt four Australian client sites for 2026 — <strong><a href="https://mowercorner.com.au">Mower Corner</a></strong>, <strong><a href="https://acparts.com.au">AC Service &amp; Parts</a></strong>, <strong><a href="https://breederschoice.com.au">Breeder’s Choice</a></strong> and <strong><a href="https://superiorshavings.com.au">Superior Shavings</a></strong> — and the last two went live this week. All four run on Jekyll and GitHub Pages, and none of them loads a CSS framework any more.</p>

<!-- more -->

<p>This post replaces three older ones about the same clients. Those described sites that no longer exist, and they described them in the language of a brochure. This is what actually got built.</p>

<h2 id="what-was-there-before">What was there before</h2>

<p>The two sister bedding sites each carried a single monolithic Jekyll layout — 1,843 lines on Breeder’s Choice, 1,380 on Superior Shavings — pulling Bootstrap 5.3.3, jQuery 3.7.1, jQuery CSV, AOS 2.3.1 and a Font Awesome kit from <strong>five different CDN hosts</strong> before either rendered a word. Mower Corner was one page — one <code class="language-plaintext highlighter-rouge">index.md</code> — reaching for jsDelivr, cdnjs, unpkg, Google Fonts and Cloudinary.</p>

<p>None of that was unreasonable in 2015. It’s just that the entire budget of every page went to downloading generic scaffolding, and what came back out the other side looked like every other Bootstrap site, because it was one.</p>

<p>What replaced it, per site:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>CSS</th>
      <th>JS</th>
      <th>Pages</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Mower Corner</td>
      <td>1,132 lines</td>
      <td>210 lines</td>
      <td>5</td>
    </tr>
    <tr>
      <td>Breeder’s Choice</td>
      <td>443 lines</td>
      <td>228 lines</td>
      <td>10</td>
    </tr>
    <tr>
      <td>Superior Shavings</td>
      <td>443 lines</td>
      <td>228 lines</td>
      <td>10</td>
    </tr>
  </tbody>
</table>

<p>Hand-written, no framework, no jQuery, no AOS, no icon kit. Google Fonts and Analytics still load, and Mower Corner still serves its imagery through Cloudinary — I’m not going to claim a purity I didn’t ship.</p>

<h2 id="four-businesses-four-registers">Four businesses, four registers</h2>

<p>The temptation with four clients at once is one design system in four colourways. These are four different trades in two states, and two of them are sister brands owned by the same person — which makes <em>differentiating</em> them the actual design problem, not a nice-to-have.</p>

<p><strong>Mower Corner — “spec plate.”</strong> Their mark is a condensed wordmark standing in a strip of grass, unchanged since the 90s, and it’s genuine equity in Colac. So the grass stayed and got redrawn as a crisp silhouette; the sky gradient and the clip-art operator silhouettes that dated it did not. The structural device is the riveted spec plate off the side of every machine in the shop, carrying the shop’s own data — phone, hours, address. Cast iron <code class="language-plaintext highlighter-rouge">#14181A</code>, safety orange <code class="language-plaintext highlighter-rouge">#F2600C</code>, the traced grass green <code class="language-plaintext highlighter-rouge">#2B7134</code>.</p>

<p><strong>Breeder’s Choice — “the bale and the spec.”</strong> A family business that has done one thing since 1994. A 14 kg bale with a moisture spec is a real product, so the product leads: real photography of their own pallets, and a kraft bale-label tag carrying the spec the way the printed wrap does. Logo greens <code class="language-plaintext highlighter-rouge">#0B4423</code> / <code class="language-plaintext highlighter-rouge">#0F6B36</code> on cream and kraft.</p>

<p><strong>Superior Shavings — “mill-side.”</strong> The sister plant, in NSW softwood country, where the pitch is proximity — from the saw into the bale with minimal handling. So it got a deliberately opposite register: iron greys and the logo’s red <code class="language-plaintext highlighter-rouge">#BF2025</code>, a numbered mill line, and the product spec set as a mono bale ticket. Same structure as Breeder’s Choice, same ethos, different plant. The two sites cross-link rather than compete.</p>

<p><strong>AC Service &amp; Parts — refine, not replace.</strong> The paddock palette and harvest photography were already working. Rebuilding would have been the expensive way to end up somewhere similar, so the budget went where it counted: contrast fixed to AA on every ground, marketing claims softened to what’s verifiable, structured data repaired, heroes self-hosted, and the logo’s teal finally let out of quarantine.</p>

<h2 id="the-stockist-finder-belongs-to-the-client">The stockist finder belongs to the client</h2>

<p>Breeder’s Choice and Superior Shavings are sold through produce stores, saddleries and pet retailers — <strong>407 and 540 of them</strong> respectively. The owner maintains both lists as CSVs in his own Dropbox, and has for years.</p>

<p>The obvious engineering move is to pull that CSV at build time: it server-renders, it’s fast, it’s cacheable. I built it the other way on purpose. The CSV is still fetched <strong>in the browser at page load</strong>, because a build-time fetch means his edits don’t appear until something triggers a deploy — and the moment his workflow depends on my CI, I’ve taken his list away from him. He edits a spreadsheet; the site updates. That’s the whole contract.</p>

<p>What did change is what it costs. jQuery plus jQuery CSV became about forty lines of native <code class="language-plaintext highlighter-rouge">fetch</code> and a CSV parser that handles quoted fields properly. The state matching runs two passes — strict uppercase abbreviation first, then a case-insensitive recovery pass — because the live CSVs mix <code class="language-plaintext highlighter-rouge">VIC</code>, <code class="language-plaintext highlighter-rouge">Vic</code> and <code class="language-plaintext highlighter-rouge">Victoria</code>, and a strict parse silently drops those rows. That’s not defensive programming for its own sake; it’s the difference between a stockist appearing and a stockist not existing.</p>

<p>The list itself renders as <strong>indexable HTML</strong> across nine pages per site — an index plus eight states — with the Google map as an enhancement on top. The previous implementation drew everything into a map and nothing else, which meant several hundred Australian retail locations were invisible to search.</p>

<h2 id="shipping-a-redesign-onto-a-live-domain-nobody-has-approved-yet">Shipping a redesign onto a live domain nobody has approved yet</h2>

<p>This is the part I’d reuse anywhere.</p>

<p>Both bedding sites belong to one owner, and a redesign he hasn’t seen can’t go live — but a preview he has to take my word for isn’t a preview either. Staging on a <code class="language-plaintext highlighter-rouge">.github.io</code> URL means he’s reviewing something that behaves subtly differently from the real thing: different domain, different SSL, no CNAME, no analytics.</p>

<p>So the new design shipped <strong>to his own live domain</strong>, at <code class="language-plaintext highlighter-rouge">/preview/</code>, months before he approved it — while the 2013 site kept serving <code class="language-plaintext highlighter-rouge">/</code> untouched. Four things made that safe:</p>

<ol>
  <li><strong>Every preview page carried <code class="language-plaintext highlighter-rouge">noindex: true</code> and <code class="language-plaintext highlighter-rouge">sitemap: false</code></strong> in front matter, so Google never saw a half-approved site or a duplicate of the live one.</li>
  <li><strong>The old layout stayed the default.</strong> The new pages opted into new layouts by path; <code class="language-plaintext highlighter-rouge">/</code> kept rendering the old one until the day of the flip.</li>
  <li><strong>Config keys were deliberately duplicated.</strong> The old layout read <code class="language-plaintext highlighter-rouge">stockistscsv</code> and <code class="language-plaintext highlighter-rouge">phone_international</code>; the new templates read <code class="language-plaintext highlighter-rouge">stockists_csv</code> and <code class="language-plaintext highlighter-rouge">phone_intl</code>. Both sets lived in <code class="language-plaintext highlighter-rouge">_config.yml</code>, each commented with which layout owned it, so cleaning up the new names could never quietly break the page that was actually serving customers.</li>
  <li><strong>The 404 page was rebuilt standalone</strong> — its own markup, its own inline CSS, linking only home and the phone number. An earlier version inherited the redesign’s header, which meant any bad URL on the live domain leaked the unreleased site and offered navigation to routes that didn’t exist yet.</li>
</ol>

<p>The flip itself, once he signed off, was mechanical: move <code class="language-plaintext highlighter-rouge">preview/*</code> to the root, repoint the URL front matter, drop the two release gates, delete the old layout and the legacy aliases it alone read. One commit per site. The design had already been running on the real domain for weeks, so there was nothing to discover at go-live.</p>

<h2 id="mower-corner-specifically">Mower Corner, specifically</h2>

<p>Mower Corner went live back in early August and got the most functional work, because a shop has state a bedding manufacturer doesn’t.</p>

<p>The header carries a <strong>live open/closed readout</strong> — “Open now · until 5pm”, or “Closed · opens tomorrow 8:30am” — computed client-side against the shop’s real trading hours in Melbourne time, with today’s row marked in the hours plate. It’s the single most-asked question about a physical shop and it was previously answered by a static table you had to cross-reference against your own watch.</p>

<p>Four <strong>service pages</strong> were written against real local search demand rather than invented: chainsaw service and sharpening, mower servicing and repairs, spare parts, and pickup and delivery. The range picked up STIHL’s iMOW robotic mowers, and Click &amp; Collect is branded STIHL-only throughout — they’re a STIHL dealer, and the online-order pipeline is STIHL’s, so implying you could get a Victa through it would have generated exactly the wrong phone calls.</p>

<p>Two implementation notes I’d defend anywhere:</p>

<ul>
  <li>The <strong>mobile menu is a <code class="language-plaintext highlighter-rouge">&lt;details&gt;</code> disclosure</strong>. It opens and closes with no JavaScript at all. The 28 lines of JS behind it only add the niceties — Escape to close, outside-click, closing on same-page anchors — so a script failure degrades to a working menu rather than a hamburger that does nothing.</li>
  <li><strong>Reveal-on-scroll replaced AOS</strong> with an <code class="language-plaintext highlighter-rouge">IntersectionObserver</code>, and it checks <code class="language-plaintext highlighter-rouge">prefers-reduced-motion</code> first. If the visitor has asked for less motion, or the browser lacks the API, everything is simply visible.</li>
</ul>]]></content><author><name>Isaac Rowntree</name></author><category term="engineering" /><category term="jekyll" /><category term="design-systems" /><category term="css" /><category term="seo" /><category term="accessibility" /><category term="github-pages" /><category term="client-work" /><category term="australia" /><summary type="html"><![CDATA[Mower Corner, AC Service & Parts, Breeder's Choice and Superior Shavings rebuilt from Bootstrap-and-jQuery to hand-written CSS on Jekyll — four distinct identities, 947 stockists rendered as indexable HTML, and a preview-on-the-live-domain pattern that let the clients sign off before anything changed.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/client-sites-rebuilt.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/client-sites-rebuilt.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">thinkbot — the ops agent that reads the 500 nobody was awake for</title><link href="https://zackdesign.biz/thinkbot/" rel="alternate" type="text/html" title="thinkbot — the ops agent that reads the 500 nobody was awake for" /><published>2026-08-20T23:00:00+00:00</published><updated>2026-08-21T00:00:00+00:00</updated><id>https://zackdesign.biz/thinkbot</id><content type="html" xml:base="https://zackdesign.biz/thinkbot/"><![CDATA[<p>The companion piece to <a href="/clawdwatch/">clawdwatch</a> — <a href="https://github.com/triptechtravel/thinkbot"><code class="language-plaintext highlighter-rouge">thinkbot</code></a> is a <a href="https://github.com/triptechtravel">Triptech Travel</a> open-source project, authored and released by Isaac Rowntree in his Triptech engineering capacity and cross-posted here on the Zack Design blog. It is a single Cloudflare Worker that takes an alert, investigates it against GitHub, Datadog, Sentry and Rollbar, and reports what actually changed. <strong>MIT licensed.</strong></p>

<!-- more -->

<p><strong>Source → <a href="https://github.com/triptechtravel/thinkbot">github.com/triptechtravel/thinkbot</a></strong> · <strong>Docs → <a href="https://triptechtravel.github.io/thinkbot/">triptechtravel.github.io/thinkbot</a></strong></p>

<h2 id="why-it-exists">Why it exists</h2>

<p>A health endpoint returned a 500 whose body named the failing dependency. It did that for five hours. The alert that fired carried <code class="language-plaintext highlighter-rouge">expected 200, got 500</code> and nothing else, because the monitor read the body to evaluate the assertion and then threw it away.</p>

<p>The cause was sitting in a response nobody kept, and nobody was awake to read it.</p>

<p>clawdwatch now keeps that body. thinkbot is the half that reads it at 3am — the step monitoring genuinely cannot do on its own. A check can tell you an endpoint returned 500. It cannot tell you that a pull request merged eleven minutes earlier, that a Sentry exception first appeared inside that window, and that a Datadog metric stepped rather than wobbled at the same moment.</p>

<h2 id="the-division-of-labour">The division of labour</h2>

<p><a href="/clawdwatch/">The clawdwatch post</a> made the case that detection should be deterministic — a threshold, a state machine, a maintenance window, and no model anywhere near the decision about whether you have a problem. thinkbot is what that decision buys you. It is the other side of a line drawn on purpose:</p>

<blockquote>
  <p><strong>Detection is code you can read at 3am. Explanation is the part where a model earns its keep.</strong></p>
</blockquote>

<p>Once something is definitely broken, the work is reading five systems and noticing what they have in common. That is genuinely well-suited to a model, and it is nobody’s favourite job at 3am.</p>

<h2 id="what-triage-actually-looks-like">What triage actually looks like</h2>

<p>The system prompt is an ordered procedure, not a vibe:</p>

<ol>
  <li><strong>Confirm it is still failing.</strong> <code class="language-plaintext highlighter-rouge">runCheckNow</code> is cheap and stops you explaining an outage that has already passed.</li>
  <li><strong>Look at the history.</strong> A single blip and a sustained outage call for different responses.</li>
  <li><strong>Look for what changed.</strong> Most outages follow a deploy, so recent merged PRs on the relevant repository is usually the highest-value call.</li>
  <li><strong>Look for corroboration.</strong> An exception that started inside the same window, or a metric that stepped rather than wobbled.</li>
  <li><strong>Check whether this endpoint has failed before</strong>, and what was concluded last time.</li>
</ol>

<p>Then it says what it found, in one short paragraph, citing the specific thing — the PR number, the exception, the ratio. And if the evidence does not support a cause, it says the cause is unclear and lists what it ruled out.</p>

<p>That last clause is the one that matters:</p>

<blockquote>
  <p>A confident wrong answer sends someone to the wrong service and costs more than an honest “unclear”.</p>
</blockquote>

<p>Conclusions worth keeping get written back to the incident with <code class="language-plaintext highlighter-rouge">annotateIncident</code>, using the short-lived signed links that arrived with the alert — so the agent records what it concluded without holding any standing credential.</p>

<h2 id="silence-is-a-valid-outcome">Silence is a valid outcome</h2>

<p>If triage found nothing that explains the failure, thinkbot replies with the single word <code class="language-plaintext highlighter-rouge">NOTHING</code>, and the channel gets nothing at all.</p>

<p>This is the design decision I would defend hardest. An agent that always produces a paragraph will always produce a paragraph — and under an incident someone is actually trying to read, filler is worse than absence. It reads as commentary. It buries the alert. An empty channel is information: it means the automated pass found nothing, and a human should look.</p>

<p>Getting this right required saying it three times over — in the prompt, in the return contract (<code class="language-plaintext highlighter-rouge">text.trim()</code> empty means “nothing worth saying”), and in each channel’s decision about whether to post. It is easy to accidentally build an agent that cannot shut up.</p>

<h2 id="two-transports-one-triage-path">Two transports, one triage path</h2>

<p><strong>Service binding, preferred.</strong> If clawdwatch runs on the same Cloudflare account, it calls thinkbot’s <code class="language-plaintext highlighter-rouge">AlertInbox</code> entrypoint directly:</p>

<div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"services"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
  </span><span class="p">{</span><span class="w"> </span><span class="nl">"binding"</span><span class="p">:</span><span class="w"> </span><span class="s2">"AGENT"</span><span class="p">,</span><span class="w"> </span><span class="nl">"service"</span><span class="p">:</span><span class="w"> </span><span class="s2">"thinkbot"</span><span class="p">,</span><span class="w"> </span><span class="nl">"entrypoint"</span><span class="p">:</span><span class="w"> </span><span class="s2">"AlertInbox"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></code></pre></div></div>

<p>The platform authenticates the call. No shared secret to rotate, no public endpoint to defend.</p>

<p><strong>Signed webhook,</strong> for anything that cannot use a binding. <code class="language-plaintext highlighter-rouge">POST /hooks/clawdwatch</code> verifies an HMAC over <code class="language-plaintext highlighter-rouge">timestamp.body</code> using clawdwatch’s own <code class="language-plaintext highlighter-rouge">verifySignature</code> rather than a local reimplementation — because two implementations of one signature scheme is a bug with a delay fuse.</p>

<p>And the sharp edge, stated in both repos because it is exactly the sort of thing that quietly becomes a vulnerability: <strong>an RPC call carries no signature.</strong> Authenticity comes from the binding. So the shared triage path never assumes one was checked.</p>

<h2 id="failing-ci-runs-get-triaged-the-same-way">Failing CI runs get triaged the same way</h2>

<p><code class="language-plaintext highlighter-rouge">POST /hooks/e2e</code> takes a signed report from a CI runner when an end-to-end suite fails.</p>

<p>A service binding is not available here — bindings are same-account only, and a GitHub runner is not on the account — so this is HMAC under thinkbot’s own header names, keyed by <code class="language-plaintext highlighter-rouge">E2E_WEBHOOK_SECRET</code>. That is deliberately a <strong>different key</strong> from the monitoring inbox: a CI runner is a different sender in a different trust domain, and leaking one key must not grant the other.</p>

<p>The payload is deliberately <em>not</em> a clawdwatch <code class="language-plaintext highlighter-rouge">AlertEvent</code>. A test run is not a synthetic check — there is no incident to annotate and no signed links to act on — so forging one would hand the agent a prompt telling it to call <code class="language-plaintext highlighter-rouge">annotateIncident</code> against an incident that does not exist. It carries the repo, the commit, the run URL, and the failures the reporter saw.</p>

<p>The split is the point. The runner holds evidence no Worker can reach — which specs failed and what they asserted. thinkbot holds the credentials the runner should not, and answers what changed around that commit.</p>

<p>Two details earned the hard way:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">loadError</code> with no failures is a distinct incident.</strong> The suite never ran, so it says nothing about whether the site is healthy. Reporting that as “0 tests failed” is how a real two-night outage read as noise.</li>
  <li><strong>This path always posts</strong>, unlike monitoring triage. There is no second notifier behind it, so silence would mean a failing nightly suite simply disappears. The headline is the floor; the triage paragraph is what gets added on top.</li>
</ul>

<h2 id="exercising-the-path-that-is-not-exercised">Exercising the path that is not exercised</h2>

<p>Removing the CI workflow’s own Slack step left exactly one delivery path and no way to tell whether it still works. You find that out during the outage the alert was meant to announce.</p>

<p>So a report may set <code class="language-plaintext highlighter-rouge">"probe": true</code>. It travels the same route and posts the same way — a probe down a <em>different</em> code path proves that path works and nothing whatsoever about the one a real failure takes — but it is labelled first and unmistakably, and it skips triage entirely.</p>

<p>That last part is not an optimisation. <strong>An agent asked to explain a non-event will invent one.</strong></p>

<h2 id="a-thing-worth-copying-keep-the-dumb-notifier">A thing worth copying: keep the dumb notifier</h2>

<p>From the production configuration on the clawdwatch side:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">notifiers</span><span class="p">:</span> <span class="p">[</span>
  <span class="nf">slack</span><span class="p">({</span> <span class="na">webhook</span><span class="p">:</span> <span class="dl">'</span><span class="s1">${SLACK_WEBHOOK_URL}</span><span class="dl">'</span> <span class="p">}),</span>
  <span class="nf">rpc</span><span class="p">({</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">thinkbot</span><span class="dl">'</span><span class="p">,</span> <span class="na">binding</span><span class="p">:</span> <span class="p">(</span><span class="nx">env</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">env</span><span class="p">.</span><span class="nx">AGENT</span> <span class="p">}),</span>
<span class="p">]</span>
</code></pre></div></div>

<p>Slack is listed first and deliberately kept. If the assistant is the only alert path, then an assistant outage is a monitoring outage — and you find that out at the worst possible moment. A failing notifier cannot affect the other one, so the plain, stupid, model-free Slack message always goes out. The agent’s paragraph is an addition to it, never a replacement for it.</p>

<p>If you take one idea from either of these projects, take that one.</p>

<h2 id="static-assets-are-a-second-front-door">Static assets are a second front door</h2>

<p>thinkbot holds a GitHub PAT, Datadog and Sentry keys, and write access to monitoring incidents. <code class="language-plaintext highlighter-rouge">workers_dev</code> and <code class="language-plaintext highlighter-rouge">preview_urls</code> are off, and every inbound route verifies its caller before doing any work.</p>

<p>That was true of the routes and still not true of the deployment, and the gap is worth writing down because it generalises to any Worker with a static bundle in front of it. <strong>Assets are matched before the Worker runs.</strong> So an asset bundle answers requests that no route guard ever sees — and while one was deployed here, that single fact produced three separate bugs:</p>

<ul>
  <li>an unauthenticated <code class="language-plaintext highlighter-rouge">GET</code> to the hostname was served from the bundle, in a route guard’s blind spot;</li>
  <li><code class="language-plaintext highlighter-rouge">/health</code> answered <code class="language-plaintext highlighter-rouge">200</code> with <code class="language-plaintext highlighter-rouge">index.html</code> instead of the handler — a liveness endpoint that <em>cannot fail</em>, reporting healthy straight through an outage;</li>
  <li>and every signed inbox returned <code class="language-plaintext highlighter-rouge">405</code>, because the asset handler rejects non-GET rather than falling through. Monitoring never noticed, because it reaches thinkbot over the RPC binding, which does not pass through assets at all.</li>
</ul>

<p>The fix is not another path in <code class="language-plaintext highlighter-rouge">run_worker_first</code> — that just moves the boundary and leaves the rest of it in place. It is having no assets at all: with the bundle gone, every request lands on the code that checks its caller.</p>

<p>There is a local trap attached, worth knowing if you ever mix Vite and Wrangler: <code class="language-plaintext highlighter-rouge">vite build</code> writes <code class="language-plaintext highlighter-rouge">.wrangler/deploy/config.json</code>, which redirects wrangler at <code class="language-plaintext highlighter-rouge">dist/</code>. Both are gitignored, so a stale redirect will keep deploying the old bundle while silently ignoring your edits to <code class="language-plaintext highlighter-rouge">wrangler.jsonc</code>. That is how a bundle you thought you had removed stays in production.</p>

<h2 id="it-is-not-about-our-estate">It is not about our estate</h2>

<p>Every source is optional, and one with no token configured reports that it is not configured rather than failing the triage. There are no built-in defaults for the GitHub owner or the Sentry org — a default would mean an unconfigured deployment quietly querying somebody else’s organisation.</p>

<p>What a deployment knows about itself lives in <code class="language-plaintext highlighter-rouge">ESTATE_NOTES</code>: free-form prose appended to the system prompt describing which repositories matter, what the Sentry projects are called, which service owns what. It is read by a model, not parsed, so plain sentences are fine.</p>

<p>Baking one organisation’s inventory into the prompt is what makes an otherwise general tool unusable by anyone else — and stale for its original owner the first time the estate changes.</p>

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

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/triptechtravel/thinkbot
npm <span class="nb">install</span> <span class="o">&amp;&amp;</span> npm <span class="nb">test</span>
</code></pre></div></div>

<p>Then give it tokens for whichever sources you use, tell it who you are with <code class="language-plaintext highlighter-rouge">GITHUB_OWNER</code> and <code class="language-plaintext highlighter-rouge">SENTRY_ORG</code>, and point <a href="/clawdwatch/">clawdwatch</a> at it. <a href="https://github.com/triptechtravel/thinkbot/blob/main/SETUP.md">SETUP.md</a> is the short version; <a href="https://github.com/triptechtravel/thinkbot/blob/main/SECURITY.md">SECURITY.md</a> is worth reading first if you plan to turn on <code class="language-plaintext highlighter-rouge">captureBodyOnFailure</code> for endpoints whose error paths can return personal data.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="cloudflare" /><category term="workers" /><category term="ai-agents" /><category term="monitoring" /><category term="typescript" /><category term="incident-response" /><category term="security" /><category term="open-source" /><summary type="html"><![CDATA[An open-source Cloudflare Worker that takes a monitoring alert or a failing CI run, correlates it against GitHub, Datadog, Sentry and Rollbar, and says what changed — in one paragraph, with the evidence cited, or says nothing at all.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/thinkbot.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/thinkbot.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Sustain — I was told to play didgeridoo for my snoring, so I built an open-source practice coach</title><link href="https://zackdesign.biz/sustain/" rel="alternate" type="text/html" title="Sustain — I was told to play didgeridoo for my snoring, so I built an open-source practice coach" /><published>2026-08-19T00:00:00+00:00</published><updated>2026-08-19T00:00:00+00:00</updated><id>https://zackdesign.biz/sustain</id><content type="html" xml:base="https://zackdesign.biz/sustain/"><![CDATA[<p>Zack Design has published <strong><a href="https://github.com/isaacrowntree/sustain">Sustain</a></strong> — an open-source deliberate-practice engine for musical instruments. It runs you through a guided daily session, measures your progress by what you can actually do rather than by points, and renders the whole thing as a lane of segments flowing toward you in a three.js night scene. It can listen through your microphone and verify you’re really playing, entirely in the browser. <strong>MIT licensed.</strong> The first instrument pack is didgeridoo, for a reason that starts with snoring.</p>

<!-- more -->

<p><strong>Source → <a href="https://github.com/isaacrowntree/sustain">github.com/isaacrowntree/sustain</a></strong> (MIT) · clone it and <code class="language-plaintext highlighter-rouge">pnpm dev</code> — there’s no hosted demo, and the app never sends your data anywhere.</p>

<h2 id="why-this-exists">Why this exists</h2>

<p>I played trombone at a decent level and then stopped for years. I also snore, enthusiastically, and the suggestion that came back was: play the didgeridoo.</p>

<p>That sounds like folk advice. It isn’t. <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC1360393/">Puhan et al., BMJ 2006</a> is a randomised controlled trial: 25 people with moderate obstructive sleep apnoea, four months, and the didgeridoo group’s apnoea–hypopnoea index dropped by about half, with significant improvements in daytime sleepiness and in how much their partners were disturbed. It later won the 2017 Ig Nobel Peace Prize, which is the kind of accolade that makes people assume the science is a joke. The science is fine. The mechanism is unglamorous: circular breathing and sustained droning are resistance training for the upper airway, and the same effect shows up in the <a href="https://pubmed.ncbi.nlm.nih.gov/19234106/">myofunctional-therapy literature</a> with no instrument involved at all.</p>

<p>Here’s the part nobody puts on the poster. The trial’s actual dose was <strong>at least 20 minutes a day, at least five days a week, for four months</strong> — and the participants beat it, averaging about 25 minutes across nearly six days. That is roughly 120 sessions. The intervention isn’t the didgeridoo; the intervention is turning up 120 times.</p>

<p>So the problem I actually had was not “how do I play the didgeridoo.” It was “what gets me to session 87.”</p>

<h2 id="did-this-already-exist">Did this already exist?</h2>

<p>Mostly, and it’s worth knowing about before you read another word of mine.</p>

<p><strong><a href="https://silentsleep.training/">Silent Sleep Training</a></strong> (~US$349) is the closest thing, and its pedigree is remarkable: it’s built by Alex Suarez’s company — Suarez is the didgeridoo teacher who taught the participants in the BMJ trial. It bundles a purpose-made silicone instrument with an app that listens and gamifies a 15-minute daily session. <strong><a href="https://didgeforsleep.com/">Didge For Sleep</a></strong> (US$167) is the other half of the idea: a real travel didgeridoo plus a sleep-apnoea-specific video curriculum, but no app, no tracking, no session engine.</p>

<p>What doesn’t exist is the overlap: a real didgeridoo, a 30–40 minute guided session, and a microphone that verifies you actually played. The snoring-adjacent apps (SnoreGym, SnoreFree, Airway Gym, Soundly) do daily arcs with their own mouth exercises. The mic-verified music trainers (Yousician, tonestro) need pitched repertoire and can’t score an unpitched drone. And a GitHub search for a didgeridoo trainer or a circular-breathing trainer returns nothing usable — the repos named “didgeridoo” are an acoustic-impedance calculator and, I promise, an operating system.</p>

<p>So: build. But build the general thing, because a drone hold and a trombone long tone are the same exercise wearing different hats.</p>

<h2 id="no-levels-no-xp-no-points">No levels, no XP, no points</h2>

<p>The easiest version of this app is a Duolingo skin: streaks, experience points, a little celebration when the bar fills. I deliberately didn’t build that, and the reason is that the didgeridoo <strong>already keeps score, honestly</strong>. Your longest unbroken drone is a real number about your actual throat. Wrapping fake currency around a real measurement doesn’t add motivation — the <a href="https://pubmed.ncbi.nlm.nih.gov/10589297/">overjustification research</a> suggests it displaces it.</p>

<p>So progression <em>is</em> the curriculum:</p>

<ul>
  <li><strong>Records, not scores.</strong> Longest drone. Longest unbroken sound. That’s the whole reward economy, and every figure on screen is a measurement of something you did.</li>
  <li><strong>Unlock by demonstration.</strong> The next drill appears when you show you can do the one before it, not when a counter fills. Circular breathing on the instrument stays locked until you’ve done the water-and-straw drills and can keep the bubbles unbroken through a sniff.</li>
  <li><strong>Perfect weeks, not daily streaks.</strong> The evidence protocol is five days a week, so rest days are drawn into the calendar as scheduled, not as failures. There is no chain to break — the <a href="https://onlinelibrary.wiley.com/doi/10.1002/ejsp.674">habit-formation work</a> found a single missed day doesn’t impair habit formation, and an app that punishes you for it is inventing a problem to sell you the cure.</li>
  <li><strong>Missing a day is recoverable, not fatal.</strong> Miss a weekday and the weekend offers a make-up session that puts the week back to whole. This is the feature I’d argue hardest for: nearly every habit app treats the miss as the punishable event, when the miss is just Tuesday being Tuesday. The thing worth protecting is the <em>return</em>.</li>
  <li><strong>You can start on a Wednesday.</strong> Join mid-week and week one asks only for the sessions that were still ahead of you — a four-day week counts as perfect if you did four. Nothing is more discouraging than a program that opens by scoring you against days that had already gone by the time you downloaded it.</li>
</ul>

<p><img src="/images/blog/sustain-home.jpg" alt="Sustain home screen: week 4 of 16, Breath mechanics, a Start session button, this week's days as filled circles, records of 34 seconds and 22 seconds, and a 16-week journey shown as a bar chart" style="width: 100%; display: block; margin: 1.5rem auto; border-radius: 12px;" /></p>

<p>That bar chart at the bottom is the sixteen-week journey, one bar per week, height being sessions completed and colour being the phase you were in. It’s the only “gamified” element in the app and it’s just a picture of the truth.</p>

<h2 id="what-a-session-looks-like">What a session looks like</h2>

<p>Thirty-five minutes of staring at a countdown is how a practice habit dies. So a session is a lane of timed segments flowing toward you — the Guitar Hero <em>sustain bar</em> stretched to the length of a breath.</p>

<p><img src="/images/blog/sustain-app.jpg" alt="Sustain session screen: a dark night scene with a lane of teal segments receding into the distance, a glowing amber ember at the near end, a 17-second countdown and the cue 'Puff your cheeks. Breathe through your nose. Keep them puffed.'" style="width: 100%; display: block; margin: 1.5rem auto; border-radius: 12px;" /></p>

<p>Most of what’s in that screen is stolen, on purpose, from software that solved these problems years ago:</p>

<ul>
  <li><strong>The strike line</strong> is a physical object, not a marker. Every rhythm game since Guitar Hero converges the lane into something that reacts — so segment boundaries <em>flare</em> as they’re consumed, and the ember is the sharpest, warmest point on screen.</li>
  <li><strong>The played trail</strong> rides behind it, an edge-on seismograph of what the microphone actually heard: bright where the drone lived, ash where it dropped. Guitar Hero’s real insight about long notes is that a hold is never static — you get continuous proof the thing is still listening.</li>
  <li><strong>Rests get a breathing halo</strong>, paced like the Apple Watch Breathe animation, because in a didgeridoo program the rests genuinely are breathing exercises.</li>
  <li><strong>The next segment brightens</strong> about one breath before it arrives, with a soft chime — telegraphing, not a countdown, so you prepare instead of react.</li>
  <li><strong>A long unbroken run escalates the world</strong>: the stars brighten, the dust thickens. Flower and Tetris Effect both make progress an environmental change rather than a meter, and it turns out that’s exactly right for something you do with your lungs.</li>
  <li><strong>The HUD fades</strong> after about ten seconds of stable playing and comes back on any input. The research on this is unambiguous that auto-hiding chrome without a way to summon it back just annoys people.</li>
</ul>

<h2 id="the-microphone-is-optional-and-local">The microphone is optional, and local</h2>

<p>Verification is tiered, and the app is fully usable at tier one:</p>

<ol>
  <li><strong>Timer.</strong> Honour system. No microphone at all. This is how the BMJ trial ran — those participants had a paper diary.</li>
  <li><strong>Energy.</strong> Raw loudness, about twenty lines of code, enough to make the scene breathe with you.</li>
  <li><strong>Pitch.</strong> <a href="https://www.npmjs.com/package/pitchy">pitchy</a>’s McLeod Pitch Method over a 4096-sample window, gated on clarity, frequency band and pitch stability, with hysteresis so a wobble doesn’t stop the clock. This is what turns “I think I held it about half a minute” into a measured record.</li>
</ol>

<p>Three practical things I learned building tier three. You must ask for the microphone with <code class="language-plaintext highlighter-rouge">echoCancellation</code>, <code class="language-plaintext highlighter-rouge">noiseSuppression</code> and <code class="language-plaintext highlighter-rouge">autoGainControl</code> all <strong>off</strong>, because WebRTC’s noise suppressor classifies a sustained tone as background noise and quietly attenuates the exact signal you’re trying to measure. Don’t use FFT bin peak-picking — at a normal FFT size the bins are about 23 Hz wide, which is useless against a 60–90 Hz fundamental. And it doesn’t matter that laptop microphones roll off below ~100 Hz, because autocorrelation-family algorithms recover the period from the harmonic stack even when the fundamental is physically missing from the recording.</p>

<p>All of it runs in the browser. Progress lives in IndexedDB, recordings live in IndexedDB, and there is no account, no server and no telemetry. The one network request the app makes is for a webfont.</p>

<h2 id="multi-instrument-by-construction">Multi-instrument by construction</h2>

<p>An instrument pack is data, not code: metrics, a declarative curriculum of phases and drills, prerequisites, and an analyzer spec. The engine compiles a pack plus today’s date into a session. The didgeridoo pack encodes the trial’s protocol — drone holds, the circular-breathing drill ladder, lip and vocal-tract work — across sixteen weeks in four phases.</p>

<p>The useful accident is that the pitch analyzer is instrument-agnostic. A trombone pack is the same detector with a different frequency band, and it gets <em>more</em> out of it, because a detector that reports exact hertz can measure long-tone steadiness in cents. Guitar is the genuinely hard one — chord detection is a different problem — and it’s marked help-wanted in the repo rather than pretended at. You don’t need to write code to contribute a pack; a curriculum written out as text is the actual work.</p>

<h2 id="where-its-at">Where it’s at</h2>

<p>It runs. The engine, the didgeridoo pack, all three microphone tiers and the web app work end to end, with tests across the packages. I’m running it locally for now rather than hosting it — for a local-first app with no backend, <code class="language-plaintext highlighter-rouge">pnpm dev</code> is the deployment.</p>

<p>The instrument is sorted too: a PVC didgeridoo from <a href="https://africandrumming.com.au/">African Drumming</a>. Plastic is not a compromise here — the BMJ trial handed its participants plastic instruments precisely because they’re easier to learn on, and the therapeutic effect comes from what your airway is doing, not from what the tube is made of.</p>

<p>So there’s a curriculum, a coach, an instrument, and an empty progress file. The piece I’m most looking forward to is the one I can do least about, because it takes exactly sixteen weeks: the app records thirty seconds of you on day one and plays it back to back with day one hundred and twenty. Ask me in December.</p>

<p>A note worth making plainly: the didgeridoo is an Aboriginal Australian instrument, known as the <em>yidaki</em> to the Yolŋu people of north-east Arnhem Land, with a ceremonial history that long predates its use as a snoring intervention. Sustain is a practice timer for a breathing exercise. It makes no claim on any of that.</p>

<p><em>Header photo by <a href="https://unsplash.com/@wilstewart3">Wil Stewart</a> on <a href="https://unsplash.com">Unsplash</a>.</em></p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="typescript" /><category term="threejs" /><category term="web-audio" /><category term="pitch-detection" /><category term="indexeddb" /><category term="vite" /><category term="pwa" /><category term="open-source" /><category term="health" /><category term="music" /><category term="claude-code" /><summary type="html"><![CDATA[An open-source deliberate-practice engine for musical instruments: guided daily sessions, progress measured by what you can actually do, and a three.js night scene that only comes alive while you play. Local-first, mic verification in the browser, no levels and no points. MIT licensed, didgeridoo is the first instrument pack.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/sustain.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/sustain.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">linework — drop a 3D model, get a rotatable technical drawing in SVG</title><link href="https://zackdesign.biz/linework/" rel="alternate" type="text/html" title="linework — drop a 3D model, get a rotatable technical drawing in SVG" /><published>2026-07-24T00:00:00+00:00</published><updated>2026-07-24T00:00:00+00:00</updated><id>https://zackdesign.biz/linework</id><content type="html" xml:base="https://zackdesign.biz/linework/"><![CDATA[<p>Zack Design has published <strong><a href="https://github.com/isaacrowntree/linework">linework</a></strong> — a tiny true-3D renderer for <em>annotated technical drawings</em>, output as plain SVG strings. Hand it 3D points, or <strong>drop in a glTF, OBJ or STL model and it draws itself</strong>: it rotates the geometry, projects it with perspective, sorts it back-to-front, and hands you SVG. The whole core is about 180 lines with zero runtime dependencies, and it exists because I went looking for exactly this and the shelf was bare.</p>

<!-- more -->

<p><strong>Live demo (drop a 3D model, watch it become a drawing) → <a href="https://isaacrowntree.com/linework/">isaacrowntree.com/linework</a></strong> · <strong>Source → <a href="https://github.com/isaacrowntree/linework">github.com/isaacrowntree/linework</a></strong> (MIT)</p>

<h2 id="why-this-exists">Why this exists</h2>

<p>I’ve been building a “will this part actually fit your bike?” planner — the sort of tool that tells you whether a given rear shock, dropper, or ebike motor bolts onto <em>your</em> frame before you spend the money. It grew out of my earlier <a href="/bike-part-planner/">bike-part-planner</a> engine, and the thing that makes it click is the drawing: an exploded, service-manual-style diagram of the bike with every part ballooned to a parts list, that you can grab and rotate to see how the pieces sit in space.</p>

<p>So I needed to render exploded technical diagrams. Rotatable ones. With dimension lines, callout balloons, and a title block — the whole drafting vocabulary. And I went looking for a library, fully expecting to find three of them.</p>

<p>There aren’t any.</p>

<h2 id="the-empty-category">The empty category</h2>

<p>The 3D tooling world splits cleanly in two, and neither half wants this job.</p>

<p>On one side is <strong>three.js</strong> and the WebGL universe. It’s superb — at shaded surfaces, lights, materials, cameras. But everything that makes a <em>technical drawing</em> read well is a fight there: crisp 1px strokes (WebGL lines are a known pain), dashed hidden-lines, line-weight hierarchy, and text callouts that have to live in a separate DOM overlay layer bolted on top of the canvas. I’d be spending all my effort making a rendering engine <em>stop</em> doing the thing it’s good at. It’s a cannon for a job that needs a technical pen.</p>

<p>On the other side are the friendly <strong>pseudo-3D toys</strong> — and there’s really only one that fits the aesthetic, <a href="https://zzz.dog/">Zdog</a>. Lovely flat-shaded look, tiny, designer-friendly. But its last release was in <strong>2019</strong>, it’s self-described as a beta, and text is a third-party plugin. Dimension lines, balloons, and leader text aren’t a nice-to-have for an exploded diagram — they’re half the drawing. A renderer that can’t do text can’t do this.</p>

<p>Nothing in between ships <em>parametric technical illustration</em>: exploded views, callouts, paper-space annotations, styling you control. So I wrote it.</p>

<h2 id="what-it-actually-is">What it actually is</h2>

<p>The pipeline is the textbook one, implemented honestly rather than faked:</p>

<blockquote>
  <p><strong>3D points → yaw/pitch rotation → perspective projection → painter’s-algorithm depth sort → SVG strings.</strong></p>
</blockquote>

<p>The important word is <em>sort</em>. Paint order is recomputed every frame from the actual depth of every shape, so when you rotate the model the near stay passes in front of the far one correctly — it’s real occlusion, not hand-authored layering that falls apart at a new angle. Circles in a plane project to correct ellipses (wheels, bearing races), boxes cull their back faces, and cylinders keep their end-caps readable rather than thinning to invisible slivers edge-on. That last one is a deliberate lie in service of legibility, which is exactly the kind of call a <em>drawing</em> library gets to make and a physics-accurate one doesn’t.</p>

<p>Here’s a bearing assembly, exploded — and this exact SVG was rendered by the library, in Node, with <strong>zero client-side JavaScript</strong>:</p>

<p><img src="/images/blog/linework-diagram.svg" alt="An exploded pillow-block bearing assembly: base plate, bearing housing, shaft and two bolts pulled apart along their assembly axes, with numbered callout balloons, a dimension line and a title block" style="max-width: 640px; width: 100%; display: block; margin: 1.5rem auto; border-radius: 8px;" /></p>

<h2 id="point-it-at-a-model-you-already-have">Point it at a model you already have</h2>

<p>That pillow block is hand-authored — but the bigger unlock is that linework will <em>import</em>. Hand it a <strong>glTF, OBJ or STL mesh</strong> (or a three.js <code class="language-plaintext highlighter-rouge">BufferGeometry</code>) and it recovers the drawing for you. A shaded 3D model carries no lines; its form lives in where the surface bends. So <code class="language-plaintext highlighter-rouge">linework/import</code> keeps only the <strong>feature edges</strong> — the outline and the hard creases, nothing from the smooth interior of a face — which is precisely the set of lines a draftsperson would ink. One call does it:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">parseGLB</span><span class="p">,</span> <span class="nx">meshToShapes</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">linework/import</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">meshes</span> <span class="o">=</span> <span class="nf">parseGLB</span><span class="p">(</span><span class="k">await</span> <span class="nx">file</span><span class="p">.</span><span class="nf">arrayBuffer</span><span class="p">());</span>   <span class="c1">// parseOBJ · parseSTL · fromBufferGeometry</span>
<span class="kd">const</span> <span class="nx">shapes</span> <span class="o">=</span> <span class="nf">meshToShapes</span><span class="p">(</span><span class="nx">meshes</span><span class="p">,</span> <span class="p">{</span> <span class="na">angle</span><span class="p">:</span> <span class="mi">25</span><span class="p">,</span> <span class="na">fit</span><span class="p">:</span> <span class="p">{</span> <span class="na">cx</span><span class="p">:</span> <span class="mi">400</span><span class="p">,</span> <span class="na">cy</span><span class="p">:</span> <span class="mi">300</span><span class="p">,</span> <span class="na">size</span><span class="p">:</span> <span class="mi">440</span> <span class="p">}</span> <span class="p">});</span>
</code></pre></div></div>

<p>Even STEP and IGES work — not by embedding a CAD kernel (tessellating trimmed-NURBS B-reps is a job for OpenCASCADE, not 180 lines), but because linework consumes <em>meshes</em>: run the file through <a href="https://github.com/kovacsv/occt-import-js">occt-import-js</a> and a ~10-line <code class="language-plaintext highlighter-rouge">fromOcct()</code> adapter maps the result straight in. The heavyweight kernel stays an optional peer; the core stays tiny.</p>

<p>Here’s a CC0 street lantern — 5,394 triangles of shaded mesh — reduced to ~2,500 feature edges and rendered as a rotatable line drawing, again entirely in Node:</p>

<p><img src="/images/blog/linework-lantern.svg" alt="A street lantern imported from a glTF mesh and rendered by linework as a blueprint-style feature-edge line drawing" style="max-width: 640px; width: 100%; display: block; margin: 1.5rem auto; border-radius: 8px;" /></p>

<p>That reframe is what made this worth releasing. “Hand-code a 3D scene” is a small audience; “bring any model you already have and get a technical drawing” is a large one — and every 3D tool on earth exports glTF. Drop your own <code class="language-plaintext highlighter-rouge">.glb</code> on the <a href="https://isaacrowntree.com/linework/">live demo</a> and watch it happen; it runs in your browser, nothing uploaded.</p>

<h2 id="why-svg-strings-specifically">Why SVG strings, specifically</h2>

<p>This is the whole bet, and it’s what neither neighbour can offer. Because the output is just markup:</p>

<ul>
  <li><strong>You style it.</strong> The library emits class names <em>you</em> define — it never bakes in a colour, a stroke width, or a font. Theme it with CSS variables, restyle it for dark mode, print it.</li>
  <li><strong>It’s crawlable and accessible.</strong> Text callouts are real text. A screen reader and Google both see them.</li>
  <li><strong>It renders on a server.</strong> <code class="language-plaintext highlighter-rouge">render()</code> is a pure function from shapes to a string — no canvas, no DOM, no browser. A static-site generator can emit finished 3D-looking diagrams at build time with nothing shipped to the client. The hero image in the repo is generated this way; the <a href="https://isaacrowntree.com/linework/">live demo</a> uses the <em>same</em> code to orbit under your pointer.</li>
</ul>

<h2 id="making-it-nice-to-author">Making it nice to author</h2>

<p>A renderer you dread writing scenes for is a renderer you don’t use, so linework ships a small authoring layer on top of the raw primitives. It reads like drafting instead of like assembling tuples — context blocks scope which animated part, which CSS tag, and which layering everything inside them belongs to:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">sketch</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">linework/sketch</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">s</span> <span class="o">=</span> <span class="nf">sketch</span><span class="p">({</span> <span class="na">yaw</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">,</span> <span class="na">pitch</span><span class="p">:</span> <span class="mf">0.16</span><span class="p">,</span> <span class="na">f</span><span class="p">:</span> <span class="mi">1500</span><span class="p">,</span> <span class="na">cx</span><span class="p">:</span> <span class="mi">460</span><span class="p">,</span> <span class="na">cy</span><span class="p">:</span> <span class="mi">320</span> <span class="p">});</span>

<span class="nx">s</span><span class="p">.</span><span class="nf">box</span><span class="p">(</span><span class="mi">300</span><span class="p">,</span> <span class="mi">380</span><span class="p">,</span> <span class="mi">320</span><span class="p">,</span> <span class="mi">42</span><span class="p">,</span> <span class="mi">40</span><span class="p">,</span> <span class="mi">80</span><span class="p">);</span>                 <span class="c1">// base plate</span>

<span class="nx">s</span><span class="p">.</span><span class="nf">part</span><span class="p">(</span><span class="dl">"</span><span class="s2">housing</span><span class="dl">"</span><span class="p">,</span> <span class="dl">'</span><span class="s1">class="prt"</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>          <span class="c1">// one &lt;g&gt;, one sort unit</span>
  <span class="nx">s</span><span class="p">.</span><span class="nf">cyl</span><span class="p">([</span><span class="mi">460</span><span class="p">,</span> <span class="mi">218</span><span class="p">],</span> <span class="mi">70</span><span class="p">,</span> <span class="mi">34</span><span class="p">,</span> <span class="o">-</span><span class="mi">34</span><span class="p">,</span> <span class="dl">"</span><span class="s2">ink</span><span class="dl">"</span><span class="p">);</span>          <span class="c1">// bearing body</span>
  <span class="nx">s</span><span class="p">.</span><span class="nf">bias</span><span class="p">(</span><span class="mf">0.6</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nf">cap</span><span class="p">([</span><span class="mi">460</span><span class="p">,</span> <span class="mi">218</span><span class="p">],</span> <span class="mi">34</span><span class="p">,</span> <span class="mi">32</span><span class="p">,</span> <span class="dl">"</span><span class="s2">ink</span><span class="dl">"</span><span class="p">));</span>
<span class="p">});</span>

<span class="nx">s</span><span class="p">.</span><span class="nf">note</span><span class="p">(</span><span class="dl">"</span><span class="s2">320 mm</span><span class="dl">"</span><span class="p">,</span> <span class="p">[</span><span class="mi">460</span><span class="p">,</span> <span class="mi">452</span><span class="p">]);</span>                      <span class="c1">// paper-space annotation</span>
<span class="nx">el</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="nx">s</span><span class="p">.</span><span class="nf">render</span><span class="p">();</span>                         <span class="c1">// depth-sorted SVG</span>
</code></pre></div></div>

<p>And because grabbing a drawing to spin it is the same twenty lines of pointer-capture boilerplate every single time, that’s a one-liner too — <code class="language-plaintext highlighter-rouge">linework/orbit</code> handles the drag, the clamps, the reset, and a gentle idle sway so an embed is alive before anyone touches it. Install and kick the tyres:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm i linework          <span class="c"># on npm — ESM, types included, zero runtime deps</span>
npm <span class="nb">test</span>                <span class="c"># projection, parallax, sorting, culling, edge extraction</span>
</code></pre></div></div>

<h2 id="the-lesson">The lesson</h2>

<p>There’s a reflex, when a problem looks 3D, to reach for the big 3D engine — and then spend a week teaching a flight simulator to draw a neat labelled diagram. The actual need was smaller and more specific than the tool everyone reaches for, and <em>nobody had written the small specific thing</em> because it sits in the crack between “serious 3D” and “cute toy.”</p>

<p>That crack is where a lot of good little libraries live. This one is ~180 lines, has no dependencies to rot, comes with tests so a wrong drawing fails loudly instead of just looking plausible, and it’s <a href="https://github.com/isaacrowntree/linework">MIT on GitHub</a> — take it, theme it, drop in your own model and draw with it. The bike planner it was born for is coming next.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="typescript" /><category term="svg" /><category term="3d" /><category term="dataviz" /><category term="technical-illustration" /><category term="open-source" /><category term="claude-code" /><summary type="html"><![CDATA[A tiny, zero-dependency renderer for annotated technical drawings: 3D points in — or a glTF/OBJ/STL mesh in — depth-sorted SVG strings out. Rotate, project, painter's-sort, paint. Import a model and it extracts the feature edges into a rotatable line drawing, no WebGL. Born from a bike-fitment planner; MIT licensed.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/linework.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/linework.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">durable-sync — offline-first sync for Cloudflare Durable Objects, no Postgres and no container</title><link href="https://zackdesign.biz/durable-sync/" rel="alternate" type="text/html" title="durable-sync — offline-first sync for Cloudflare Durable Objects, no Postgres and no container" /><published>2026-07-16T00:00:00+00:00</published><updated>2026-07-16T00:00:00+00:00</updated><id>https://zackdesign.biz/durable-sync</id><content type="html" xml:base="https://zackdesign.biz/durable-sync/"><![CDATA[<p>Zack Design has published <strong><a href="https://github.com/isaacrowntree/durable-sync">durable-sync</a></strong> — a small, zero-dependency library that gives Cloudflare Durable Objects an offline-first sync loop. An append-only op log on the server, a durable outbox on the client, and an honest status you can show users. <strong>No Postgres, no long-running container, no WebSocket.</strong> It’s the sync layer extracted from <a href="/rampset/">Rampset</a>, the offline-first gym app, cleaned up and shipped on its own. <strong>MIT licensed</strong>, on npm, and it depends on nothing.</p>

<!-- more -->

<p><strong>Docs &amp; live demo → <a href="https://isaacrowntree.com/durable-sync/">isaacrowntree.com/durable-sync</a></strong> · <strong>Source → <a href="https://github.com/isaacrowntree/durable-sync">github.com/isaacrowntree/durable-sync</a></strong> (MIT) · <strong>npm → <a href="https://www.npmjs.com/package/durable-sync"><code class="language-plaintext highlighter-rouge">durable-sync</code></a></strong></p>

<h2 id="why-this-exists">Why this exists</h2>

<p>If you want offline-first sync in 2026, the credible engines — Zero, Electric, PowerSync — all want <strong>Postgres and a long-running container</strong>. On an all-Cloudflare stack that’s a second backend to run, forever. They’re genuinely better than this at almost everything; that’s not the point. The point is the tax.</p>

<p>Meanwhile the platform already hands you the hard part for free: <strong>a Durable Object is a single-threaded ordering point, per user.</strong> That’s the exact thing everyone else reaches for Postgres to get. Give each user their own DO with <code class="language-plaintext highlighter-rouge">idFromName(userKey)</code> and write-ordering stops being a distributed-systems problem and becomes a language feature. Once you have that, the rest of “sync” is just an outbox, a cursor, and a great deal of care about what “success” means.</p>

<p>So durable-sync is small on purpose. It’s a <strong>primitive, not a database.</strong> No conflict resolution — ops are immutable facts appended to a log, and if your writes are <em>events</em> (“this workout happened”) rather than edits, you don’t have a conflict problem. HTTP, not WebSocket — a socket is useless in a basement, and gyms are Faraday cages, so sync happens when the app is open and the network exists. Bring your own IndexedDB.</p>

<h2 id="the-four-things-that-are-easy-to-get-wrong">The four things that are easy to get wrong</h2>

<p>Here’s the honest reason this is a package and not a gist. Sync that <em>looks</em> like it works and sync that <em>actually</em> works are separated by a handful of failures that are individually obvious and collectively brutal — because every one of them fails <strong>silently</strong>. Each of these shipped to production in the app this came from:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">res.ok</code> is not evidence.</strong> Behind an auth proxy like Cloudflare Access, an expired session redirects to a <em>same-origin</em> login page — which <code class="language-plaintext highlighter-rouge">fetch</code> quietly follows and reports as a <strong>200</strong>. Drain your outbox on <code class="language-plaintext highlighter-rouge">res.ok</code> and you’ve just deleted writes that never reached the server. durable-sync validates that a reply actually came from the journal before it touches the queue.</li>
  <li><strong>A cursor only means something against the log that issued it.</strong> Reset the log and every client is holding a sequence number from a log that no longer exists — usually pointing <em>past</em> the rebuilt one, so <code class="language-plaintext highlighter-rouge">seq &gt; cursor</code> matches nothing and the client <strong>silently never syncs again.</strong> Every pull carries an <code class="language-plaintext highlighter-rouge">epoch</code>; a client that sees a new one replays from zero. Apply is idempotent, so replay is cheap.</li>
  <li><strong>Pulling is not always safe.</strong> In Rampset, a remote op landing <em>mid-workout</em> rewrote the working weight that the finish logic reads back — and silently dropped a 5×5 to a 3×5. Pushing is always safe; pulling isn’t. A <code class="language-plaintext highlighter-rouge">canPull()</code> gate defers the pull while the user is in the middle of something, and eventual consistency makes waiting free.</li>
  <li><strong>The gate has to be the only door.</strong> The journal is addressed by a <em>server-side</em> identity while the ops carry whatever the client selected — so writing as the wrong one files data under someone else, permanently, in an append-only log. The engine has a <code class="language-plaintext highlighter-rouge">canWrite()</code> gate; the transport that could bypass it simply <strong>isn’t exported.</strong> There is one way in.</li>
</ul>

<p>None of these are things you’d think to test for until they’ve bitten you. That scar tissue is the actual product.</p>

<h2 id="what-you-write">What you write</h2>

<p>The server is a Durable Object you extend and export. Its methods are native DO RPC — you call them on the stub, typed, with no request-building in between:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// worker.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">SyncJournal</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">durable-sync/server</span><span class="dl">"</span><span class="p">;</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nc">Journal</span> <span class="kd">extends</span> <span class="nc">SyncJournal</span> <span class="p">{}</span>

<span class="c1">// your route — forward exactly the methods a client should reach</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">POST</span><span class="p">(</span><span class="nx">req</span><span class="p">:</span> <span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">ops</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">req</span><span class="p">.</span><span class="nf">json</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">journal</span> <span class="o">=</span> <span class="nx">env</span><span class="p">.</span><span class="nx">JOURNAL</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="nx">env</span><span class="p">.</span><span class="nx">JOURNAL</span><span class="p">.</span><span class="nf">idFromName</span><span class="p">(</span><span class="nx">userKey</span><span class="p">));</span>
  <span class="k">return</span> <span class="nx">Response</span><span class="p">.</span><span class="nf">json</span><span class="p">(</span><span class="k">await</span> <span class="nx">journal</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="nx">ops</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note what you <em>don’t</em> forward: <code class="language-plaintext highlighter-rouge">reset()</code>. The DO has it, but a client reaches only the methods you wire to a route — that’s the whole access model, no router second-guessing you. The client is local-first: commit to IndexedDB, queue the op, let the network catch up.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">createSync</span><span class="p">,</span> <span class="nx">localStorageCursor</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">durable-sync/client</span><span class="dl">"</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">sync</span> <span class="o">=</span> <span class="nf">createSync</span><span class="p">({</span>
  <span class="na">endpoint</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/api/sync</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">outbox</span><span class="p">:</span> <span class="nf">dexieOutbox</span><span class="p">(</span><span class="nx">db</span><span class="p">.</span><span class="nx">outbox</span><span class="p">),</span>          <span class="c1">// durable — it may hold the only copy</span>
  <span class="na">cursor</span><span class="p">:</span> <span class="nf">localStorageCursor</span><span class="p">(</span><span class="dl">"</span><span class="s2">myapp.cursor</span><span class="dl">"</span><span class="p">),</span>
  <span class="k">async</span> <span class="nf">apply</span><span class="p">(</span><span class="nx">op</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* idempotent: an op can arrive twice */</span> <span class="p">},</span>
  <span class="na">canPull</span><span class="p">:</span> <span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="p">(</span><span class="k">await</span> <span class="nf">somethingInProgress</span><span class="p">()),</span>
<span class="p">});</span>
</code></pre></div></div>

<p>There’s a <strong><a href="https://github.com/isaacrowntree/durable-sync/tree/main/examples/notes">runnable example</a></strong> in the repo — a Worker plus a browser client with a real IndexedDB outbox — and a <strong><a href="https://isaacrowntree.com/durable-sync/">live demo on the docs site</a></strong> where you can cut the network, watch writes pile up in the outbox with no sequence number, then reconnect and watch them drain. That widget <em>is</em> the library.</p>

<h2 id="when-to-use-something-else">When to use something else</h2>

<p>Being clear so you don’t adopt it and find out: no conflict resolution, no pagination (a pull returns everything after the cursor in one response — fine for thousands of ops, not millions), no live push, no auth, and nothing runs while the app is closed because Safari has no Background Sync. If you need those, you want a real sync engine and the Postgres that comes with it — there’s an <a href="https://isaacrowntree.com/durable-sync/vs.html">honest comparison</a> against Zero, Electric, PowerSync and Cloudflare’s own partysync on the docs site. If you need two people editing the same record to <em>merge</em>, you want a CRDT — Yjs or Automerge.</p>

<p>The slice where durable-sync is the right answer is narrow: you’re all-Cloudflare, your writes are events, and the app has to keep working on a train. Inside that slice, it’s a few kilobytes and no second backend.</p>

<h2 id="the-lesson">The lesson</h2>

<p>Extracting a library from a working app is a good forcing function: it makes you separate the parts that are genuinely reusable from the parts that were only ever about your problem. What surprised me is how little of the value was in the code. The op log is a hundred lines. The outbox is an interface. Anyone could write those in an afternoon — and then spend the next year discovering, one silent data-loss bug at a time, the four things above.</p>

<p>That year is what’s in the package. The code is small; the scar tissue is the product.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="cloudflare" /><category term="durable-objects" /><category term="typescript" /><category term="offline-first" /><category term="local-first" /><category term="sync" /><category term="pwa" /><category term="event-sourcing" /><category term="open-source" /><category term="claude-code" /><summary type="html"><![CDATA[A small, zero-dependency TypeScript library that gives Cloudflare Durable Objects an offline-first sync loop: an append-only op log on the server, a durable outbox on the client. No Postgres, no long-running container, no WebSocket. Extracted from Rampset, MIT licensed, on npm.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/durable-sync.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/durable-sync.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Sovereign — an autonomous IBKR fund that trades through bezant, with guardrails because it’s real money</title><link href="https://zackdesign.biz/sovereign/" rel="alternate" type="text/html" title="Sovereign — an autonomous IBKR fund that trades through bezant, with guardrails because it’s real money" /><published>2026-07-08T00:00:00+00:00</published><updated>2026-07-08T00:00:00+00:00</updated><id>https://zackdesign.biz/sovereign</id><content type="html" xml:base="https://zackdesign.biz/sovereign/"><![CDATA[<p>Zack Design has published <a href="https://github.com/isaacrowntree/sovereign-ibkr-fund"><strong>Sovereign</strong></a> — an open-source, multi-agent portfolio fund for Interactive Brokers. It’s the companion to <a href="https://github.com/isaacrowntree/bezant">bezant</a>: bezant mints the access, Sovereign spends it. Nine deterministic TypeScript agents hold a model portfolio, detect drift, size trades with real risk controls, and execute through bezant — standalone, or under whatever scheduler you already run.</p>

<!-- more -->

<p><strong>Source → <a href="https://github.com/isaacrowntree/sovereign-ibkr-fund">github.com/isaacrowntree/sovereign-ibkr-fund</a></strong> (Apache-2.0 OR MIT) · Built on <strong><a href="https://github.com/isaacrowntree/bezant">bezant</a></strong></p>

<h2 id="why-it-exists">Why it exists</h2>

<p>I had a portfolio problem that a lot of people quietly have: one winner had run so hard it became <strong>40%+ of the book</strong>. Great on the way up, a single point of failure on the way down. Fixing that by hand — trimming the concentration, funding a diversified target, doing it without fat-fingering a real order — is exactly the kind of repetitive, high-stakes, easy-to-get-wrong work you should not be doing manually at 11pm.</p>

<p>So the fund isn’t a get-rich bot. It’s a <strong>discipline engine</strong>: hold a target allocation, notice when reality drifts from it, and make the smallest correct trades to close the gap — with enough guardrails that a bad market-data tick or a logic bug can’t do real damage.</p>

<p>bezant already gave every language typed access to IBKR’s Client Portal API. Sovereign is what happens when you build an actual fund on that foundation and take the “it’s real money” part seriously.</p>

<h2 id="nine-agents-no-llm-calls">Nine agents, no LLM calls</h2>

<p>The agents are plain TypeScript <code class="language-plaintext highlighter-rouge">--once</code> processes — <strong>deterministic, no model calls in the loop.</strong> That’s deliberate: I want a trade decision to be a pure function of the inputs, reproducible and auditable, not a sample from a distribution.</p>

<table>
  <thead>
    <tr>
      <th>Agent</th>
      <th>Job</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Managing Partner</strong></td>
      <td>Orchestrates the fund, snapshots NAV and positions</td>
    </tr>
    <tr>
      <td><strong>Portfolio Strategist</strong></td>
      <td>HRP / Black-Litterman weights, drift detection, sizes rebalance orders</td>
    </tr>
    <tr>
      <td><strong>Quant Analyst</strong></td>
      <td>Regime detection, factor regression</td>
    </tr>
    <tr>
      <td><strong>Risk Manager</strong></td>
      <td>VaR / CVaR, drawdown control, volatility targeting</td>
    </tr>
    <tr>
      <td><strong>Execution Bot</strong></td>
      <td>Places orders through bezant — window-gated, capped, reconciled</td>
    </tr>
    <tr>
      <td><strong>Tax Optimizer</strong></td>
      <td>FIFO lots, tax-loss harvesting, wash-sale tracking</td>
    </tr>
    <tr>
      <td><strong>Hedger</strong></td>
      <td>Options overlay (covered calls, protective puts)</td>
    </tr>
    <tr>
      <td><strong>Research Scout</strong></td>
      <td>Price monitoring and alerts</td>
    </tr>
    <tr>
      <td><strong>Observer</strong></td>
      <td>Ingests the WebSocket fill/event stream</td>
    </tr>
  </tbody>
</table>

<p>Under the hood there’s a real quant toolbox: Hierarchical Risk Parity, Black-Litterman, risk-parity, Ledoit-Wolf shrinkage covariance, a regime overlay, and vol targeting — all backtestable.</p>

<h2 id="the-guardrails-are-the-point">The guardrails are the point</h2>

<p>Anyone can write a loop that places market orders. The interesting engineering is everything that stops it from doing something stupid with real money:</p>

<ul>
  <li><strong>Validation-first execution.</strong> Before it will batch a rebalance, the executor proves a live fill on the single smallest order and confirms it against IBKR’s own execution records. No confirmed fill, no batch.</li>
  <li><strong>Executions are authoritative.</strong> Fills are reconciled against IBKR’s execution log, and the trade ledger is <strong>idempotent</strong> — the same fill can never be recorded twice, even if a confirmation arrives by two paths.</li>
  <li><strong>Hard caps.</strong> Absolute backstops on per-order notional, per-order % of NAV, and per-run notional — independent of the sizing math. A garbled input can’t size a monster order.</li>
  <li><strong>Data-sanity gates.</strong> If NAV or a price tick looks impossible (zeroed, or a 100× move with no cash flow), the strategist refuses to generate orders that cycle rather than trade against garbage.</li>
  <li><strong>Drawdown control.</strong> De-risk and hard-stop thresholds that pull exposure down when the book is bleeding.</li>
  <li><strong>State lives outside the checkout.</strong> Positions and ledger sit in <code class="language-plaintext highlighter-rouge">STATE_DIR</code>, never entangled with the code — so a deploy can never clobber your holdings.</li>
</ul>

<p>Most of these exist because the honest way to build this is to assume your own code will misbehave and make sure the blast radius is bounded when it does.</p>

<h2 id="standalone-or-under-whatever-you-run">Standalone, or under whatever you run</h2>

<p>Every agent is just <code class="language-plaintext highlighter-rouge">node dist/agents/&lt;name&gt;.js --once</code>. That one contract means Sovereign doesn’t care how it’s scheduled:</p>

<ul>
  <li><strong>Built-in scheduler</strong> — <code class="language-plaintext highlighter-rouge">npm start</code> runs the whole fund on a cadence, zero dependencies.</li>
  <li><strong>cron / systemd</strong> — point timers at the <code class="language-plaintext highlighter-rouge">--once</code> scripts; examples in <code class="language-plaintext highlighter-rouge">deploy/</code>.</li>
  <li><strong>Any orchestrator</strong> — set <code class="language-plaintext highlighter-rouge">ENABLE_SCHEDULER=false</code> and let your platform drive the same scripts.</li>
</ul>

<p>The core never imports the scheduler, so it genuinely runs both ways.</p>

<h2 id="your-book-stays-yours">Your book stays yours</h2>

<p>The public repo ships a <strong>generic sample portfolio</strong> (a diversified ETF template) and runs against <strong>paper</strong> out of the box. Your real allocation goes in a gitignored <code class="language-plaintext highlighter-rouge">src/portfolios/local.ts</code> that takes precedence automatically and never leaves your machine. Everything else — caps, thresholds, cadences, optimizer choice — is environment-driven. Nothing about <em>your</em> positions lives in the source.</p>

<h2 id="backtesting">Backtesting</h2>

<p>There’s a full backtest engine (HRP, risk-parity, Black-Litterman, regime overlay, vol targeting). The historical dataset is gitignored — you generate it yourself from Yahoo Finance with <code class="language-plaintext highlighter-rouge">npm run fetch-data</code>, and the backtest suites skip cleanly until it exists, so a fresh clone is green on the first <code class="language-plaintext highlighter-rouge">npm test</code>.</p>

<h2 id="status-and-licensing">Status and licensing</h2>

<ul>
  <li><strong>v0.1</strong> — runs end-to-end against IBKR paper accounts; the API and agent set will evolve.</li>
  <li><strong>Dual-licensed Apache-2.0 OR MIT.</strong></li>
  <li><strong>Requires a running <a href="https://github.com/isaacrowntree/bezant">bezant</a> gateway</strong> — that’s how it talks to IBKR.</li>
  <li><strong>Not affiliated with Interactive Brokers.</strong> This is not financial advice. It places real trades — <strong>start on paper</strong>, and understand every guardrail before you flip <code class="language-plaintext highlighter-rouge">TRADING_MODE=live</code>.</li>
</ul>

<p>If bezant was about making IBKR <em>programmable</em>, Sovereign is about making a portfolio <em>governable</em> — trading on rules you can read, with brakes you can trust. If you’re running money through IBKR and you’d rather it followed a written policy than your 11pm instincts, clone it and point it at your own bezant. Contributions welcome — especially on the optimizers and the risk engine.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="ibkr" /><category term="trading" /><category term="typescript" /><category term="portfolio" /><category term="quant" /><category term="risk" /><category term="backtesting" /><category term="interactive-brokers" /><category term="open-source" /><category term="claude-code" /><category term="bezant" /><summary type="html"><![CDATA[An open-source, multi-agent portfolio fund for Interactive Brokers, built on top of bezant. Deterministic TypeScript agents handle allocation, risk, tax, and execution — with validation-first trading, hard caps, and data-sanity gates. Runs standalone or under any scheduler. Dual-licensed Apache/MIT.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/sovereign.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/sovereign.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Rampset — I got fired as a customer, so I built the gym app myself and open-sourced it</title><link href="https://zackdesign.biz/rampset/" rel="alternate" type="text/html" title="Rampset — I got fired as a customer, so I built the gym app myself and open-sourced it" /><published>2026-07-06T00:00:00+00:00</published><updated>2026-07-06T00:00:00+00:00</updated><id>https://zackdesign.biz/rampset</id><content type="html" xml:base="https://zackdesign.biz/rampset/"><![CDATA[<p>Zack Design has published <strong><a href="https://github.com/isaacrowntree/rampset">Rampset</a></strong> — an open-source, offline-first strength-training PWA that replaces the app I logged eleven years of workouts in. It runs three different training styles on one engine, works in a gym with zero signal, optionally syncs between devices through a per-user Durable Object, and snapshots every workout to R2. It’s <strong>MIT licensed</strong> and built to self-host: your lifters, your data, your Cloudflare account, up in about ten minutes.</p>

<!-- more -->

<p><strong>Site &amp; guide → <a href="https://isaacrowntree.github.io/rampset">isaacrowntree.github.io/rampset</a></strong> · <strong>Source → <a href="https://github.com/isaacrowntree/rampset">github.com/isaacrowntree/rampset</a></strong> (MIT)</p>

<h2 id="why-this-exists">Why this exists</h2>

<p>I was a StrongLifts user from <strong>2015 to 2026</strong> — 1,221 logged workouts, a grandfathered subscription I’d held since 2018, and honestly no complaints for over a decade. It did the one thing a 5×5 app has to do: tell me what to lift today and remember what I lifted yesterday.</p>

<p>Then a sign-in bug locked me out of my own subscription. I did the support dance — reinstalled the app, removed and re-added every Google account on the phone, followed every step of every linked help article, sent screenshots. After a morning of back-and-forth, support resolved it their way: <strong>they cancelled my subscription.</strong> I hadn’t asked for that. The legacy plan I’d been quietly grandfathered on for years ceased to exist the moment it was cancelled, and the only path back was the shiny new trial at roughly six times my old price.</p>

<p>Well — screw that. I write software for a living, my workout history exports to CSV, and it’s 2026: with Claude Code in the loop, “I’ll just build it myself” is no longer a threat you mutter into an email draft and delete. It’s a Saturday.</p>

<h2 id="three-ways-to-train">Three ways to train</h2>

<p>The interesting design constraint wasn’t me — it was that my wife trains too, and her program is nothing like mine. That forced a better architecture than a clone would have had, and it’s why Rampset ships with three shapes on one engine:</p>

<ul>
  <li><strong>Program mode</strong> — classic StrongLifts-style <strong>5×5</strong>, A/B, with variants from Lite to Ultra Max. The app prescribes: tap a plate-shaped circle to log a set, warm-ups ramp with per-side plate math, +2.5 kg linear progression, graduated deloads after time off, and the full stall protocol (5×5 → 3×5 → 1×5).</li>
  <li><strong>Madcow mode</strong> — weekly ramped 5×5: heavy / light / intensity days, back-off sets, and a Friday PR set that sets next week’s top weight.</li>
  <li><strong>Routine mode</strong> — a Strong-style layout: exercise cards with set rows (previous · kg · reps · ✓), everything prefilled from the last session, per-exercise rest timers, timed and bodyweight sets. You prescribe; the app remembers.</li>
</ul>

<p>Same schema, same logging engine, same charts — the mode is just a flag on the program. Both of our full histories came in through CSV importers for the two incumbent apps’ export formats, auto-detected and idempotent, verified against the real files. And export gives it all back whenever you want — no lock-in is the whole point.</p>

<p><img src="/images/blog/rampset-app.png" alt="Rampset home screen: dark OLED UI showing the next 5×5 workouts with working weights and a Start workout button" style="max-width: 320px; width: 100%; display: block; margin: 1.5rem auto; border-radius: 24px;" /></p>

<h2 id="the-parts-im-fond-of">The parts I’m fond of</h2>

<ul>
  <li><strong>Offline-first for real.</strong> Gyms are Faraday cages. IndexedDB is the source of truth on-device; the service worker keeps the shell alive; a screen wake-lock holds the display on mid-workout; and a rest timer keeps counting on a wall-clock deadline even when the phone locks, then rings a WebAudio bell it synthesises itself. No signal just means sync happens later.</li>
  <li><strong>Sync without a database server.</strong> Each person gets their own SQLite-backed Durable Object — a single-threaded, per-user journal that makes write ordering a non-problem instead of a distributed-systems problem. Finished workouts push as idempotent ops; fresh devices pull. R2 holds dated snapshots underneath as the disaster layer.</li>
  <li><strong>No auth code, no personal data in the repo.</strong> Identity is handled entirely by <strong>Cloudflare Access</strong> (a free zero-trust login) — the app itself contains zero auth code. Lifters are defined by an env var that’s gitignored; the committed code carries only generic program templates. Nothing about you or your training lives in the source.</li>
  <li><strong>TDD the whole way.</strong> 240 tests: the progression engine, both CSV dialects, the sync journal, backup round-trips, even “a rest timer survives navigating away mid-countdown.” The engines are pure functions in <code class="language-plaintext highlighter-rouge">src/lib/</code>, each with a failing-test-first workflow waiting — because editing them is the point.</li>
  <li><strong>It feels like an app.</strong> OLED-black UI, floating dock, haptic ticks on set logging, sheets that close with the Android back gesture, splash screens, home-screen shortcuts. The bar for “doesn’t feel like a website” is a hundred small details, and a multi-agent review fleet is very good at finding all hundred.</li>
</ul>

<h2 id="run-your-own">Run your own</h2>

<p>Rampset is built to be yours. Clone it, point it at your lifters, and either use it locally or deploy your own copy to Cloudflare:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/isaacrowntree/rampset
<span class="nb">cd </span>rampset
npm <span class="nb">install
cp</span> .env.example .env.local     <span class="c"># your lifters, units, starting weights</span>
npm run dev                    <span class="c"># → http://localhost:3000</span>
npm <span class="nb">test</span>                       <span class="c"># the engines, importers, store, components</span>
</code></pre></div></div>

<p>To put it online, <code class="language-plaintext highlighter-rouge">npm run deploy</code> ships it to Cloudflare Workers via the OpenNext adapter; then you add a <strong>Cloudflare Access</strong> application in front of your Worker and allow your lifters’ emails. Workers and Access are free for a household. The one paid gate is <strong>R2</strong> for cloud backups — the data sits inside R2’s 10 GB free tier, but Cloudflare wants a card on file to enable it. Don’t want to add one? Drop the <code class="language-plaintext highlighter-rouge">BACKUPS</code> binding: the app is fully offline-first on local IndexedDB, and <strong>Settings → Export</strong> hands you a complete CSV backup whenever you want.</p>

<h2 id="the-lesson">The lesson</h2>

<p>There’s a quiet lesson in this for anyone running a subscription product: your most loyal decade-long customers aren’t locked in by your data-export button being hard to find anymore. They’re one bad support interaction away from an export file, a coding agent, and a very productive Saturday afternoon.</p>

<p>And here’s the part that should really keep you up at night. This time they don’t even have to build it. The export importers, the two proven programs, the offline sync, the plate math — it’s all sitting in a public repo under a licence that says <em>make it yours</em>. Take it to the gym. Take it somewhere no subscription can reach you.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="open-source" /><category term="product" /><category term="nextjs" /><category term="react" /><category term="pwa" /><category term="cloudflare" /><category term="durable-objects" /><category term="typescript" /><category term="tdd" /><category term="fitness" /><category term="open-source" /><category term="claude-code" /><summary type="html"><![CDATA[An open-source, offline-first barbell training PWA — guided 5×5 and Madcow programs, freeform routines, per-user Durable Object sync, R2 snapshots — born the day my eleven-year StrongLifts subscription was cancelled without me asking. MIT licensed, self-host it in about ten minutes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/rampset.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/rampset.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Taking over Sequin: adopting an orphaned CDC engine, fixing the Dragonfly crash, and putting it behind Cloudflare Access</title><link href="https://zackdesign.biz/taking-over-sequin/" rel="alternate" type="text/html" title="Taking over Sequin: adopting an orphaned CDC engine, fixing the Dragonfly crash, and putting it behind Cloudflare Access" /><published>2026-07-01T00:00:00+00:00</published><updated>2026-07-01T00:00:00+00:00</updated><id>https://zackdesign.biz/taking-over-sequin</id><content type="html" xml:base="https://zackdesign.biz/taking-over-sequin/"><![CDATA[<p><a href="https://github.com/sequinstream/sequin">Sequin</a> is an open-source Postgres change-data-capture engine written in Elixir. It tails a Postgres logical replication slot and streams every insert, update, and delete out to sinks — Typesense, webhooks, Kafka, SQS — with Elixir functions in the middle to transform or filter each row. I use it to keep the search index and a few downstream services for <a href="https://campermate.com">CamperMate</a> — the free-camping and campground app across Australia and New Zealand, <a href="https://apps.apple.com/app/campermate/id578975305">iOS</a> and <a href="https://play.google.com/store/apps/details?id=nz.co.campermate.app">Android</a>, 1M+ downloads — in lockstep with the source-of-truth Postgres database.</p>

<p>This is the story of how a tool I adopted became a tool I help maintain: how I found it, ran it in production, watched it fall over for a reason that wasn’t really its fault, went looking for help from a company that no longer existed, and ended up forking it, fixing it, and extending it.</p>

<!-- more -->

<h2 id="why-i-reached-for-sequin">Why I reached for Sequin</h2>

<p>The problem Sequin solves is the boring, load-bearing kind. The POI, review, and translation data lives in Postgres (on <a href="https://neon.tech">Neon</a>). Search runs on <a href="https://typesense.org">Typesense</a>. Several other services — a translation workflow, a delta cache for the mobile app — need to know the instant a row changes. The naive version of this is a spray of application-level hooks and cron jobs that drift out of sync the moment anything fails silently.</p>

<p>CDC inverts that. Postgres already writes every change to its write-ahead log; Sequin reads the log and fans each change out reliably, with retries and backfills, so the index and the database can’t disagree for long. The whole thing is a dozen sinks driven by a single declarative <a href="https://github.com/sequinstream/sequin"><code class="language-plaintext highlighter-rouge">sequin.yaml</code></a>, version-controlled and applied from CI:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   Postgres (Neon)  →  Sequin  →  Typesense collections (POIs, reviews, translations)
   logical slot        transforms →  Webhooks (translation pipeline, delta sync)
</code></pre></div></div>

<p>The transforms are the nice part. A small Elixir function denormalises a POI’s feature list into boolean columns, builds language-suffixed fields (<code class="language-plaintext highlighter-rouge">description_en</code>, <code class="language-plaintext highlighter-rouge">description_mi</code>) from a tall translations table, and filters out non-public rows before they ever reach the public index. It’s the right amount of logic in the right place, and for a good while it just worked.</p>

<h2 id="the-night-dragonfly-took-sequin-with-it">The night Dragonfly took Sequin with it</h2>

<p>Then came outages that made no sense. Sequin would be humming along, and then — with no deploy, no traffic spike, no obvious cause — every consumer would stop at once. The sinks would go cold, the index would fall behind, and the only fix was a restart.</p>

<p>The correlation, once I traced it, was infuriating: it happened whenever <strong>Dragonfly</strong> — the Redis-compatible store Sequin uses for coordination, running as a managed service on <a href="https://railway.app">Railway</a> — redeployed itself. A routine maintenance update to a <em>dependency</em> was taking down the whole engine.</p>

<p>The mechanism is a textbook Erlang supervision-tree footgun. Sequin uses a Redis-backed mutex to elect a single leader across nodes — a <code class="language-plaintext highlighter-rouge">MutexOwner</code> GenServer that holds a lock and refreshes it before it expires. When Dragonfly restarted, the connection blipped, the mutex refresh failed, and <code class="language-plaintext highlighter-rouge">MutexOwner</code> did the most literal possible thing: it crashed. And because it sits under a supervisor with a <code class="language-plaintext highlighter-rouge">:one_for_all</code> restart strategy, its death took <strong>every sibling down with it</strong> — the entire runtime supervisor, all consumers, all sinks. A two-second Redis blip became a total outage that only a human restart would clear.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MutexedSupervisor (:one_for_all)
  └── MutexOwner  ✗  Redis blips → mutex refresh fails → crash
        ⇒ :one_for_all fires ⇒ every consumer &amp; sink is torn down with it
</code></pre></div></div>

<p>That’s not really Sequin doing something wrong so much as an assumption — “Redis is always there” — that doesn’t hold on a platform where your Redis can redeploy under you at any moment.</p>

<h2 id="upstream-had-gone-dark">Upstream had gone dark</h2>

<p>So I did the normal thing: went to fix it upstream, or at least to ask. And found the lights off. The project had moved into <strong>maintenance mode</strong>, the company behind Sequin had wound down, and there was no one on the other end of the issue tracker to merge a patch or ship a release. A tool at the centre of a data pipeline had, effectively, been orphaned.</p>

<p>This is the quiet risk of building on someone else’s open source: the licence guarantees you the code, but nothing guarantees you a maintainer. When the maintainer disappears, you have exactly two choices — rip the dependency out, or adopt it.</p>

<h2 id="so-i-adopted-it">So I adopted it</h2>

<p>Ripping out CDC and rebuilding the sink pipeline from scratch would have been weeks of work to end up back where I started. Adoption was the better trade. The project got forked into <a href="https://github.com/triptechtravel/sequin"><code class="language-plaintext highlighter-rouge">github.com/triptechtravel/sequin</code></a>, with its own build and release pipeline, and from then on it was treated as what it now was: code I own.</p>

<p>That meant the unglamorous infrastructure of ownership. A GitHub Actions workflow that builds a patched image and pushes it to GHCR. Deployment to Railway. Fixing the parts of the build that assumed a corporate CI — a missing <code class="language-plaintext highlighter-rouge">cmake</code> for a Kafka NIF, a Sentry DSN that was baked in at build time and now had to be optional. None of it is exciting. All of it is the price of being the maintainer instead of a user.</p>

<h2 id="fixing-the-crash-properly">Fixing the crash properly</h2>

<p>With the fork in hand, the Dragonfly bug got fixed at the root. <code class="language-plaintext highlighter-rouge">MutexOwner</code> no longer treats a Redis error as fatal. While it holds the mutex and Redis becomes unreachable, it now <strong>retries indefinitely with exponential backoff</strong> (capped at an hour) instead of crashing — Redis going down should degrade Sequin gracefully, never take it out. When Redis comes back, it re-acquires the lock and resumes as if nothing happened. The invalid GenStateMachine stop value that caused the original crash got corrected too, and the LiveView metrics pages were hardened so a Redis blip renders an empty chart instead of a <code class="language-plaintext highlighter-rouge">MatchError</code>.</p>

<p>The part I’m most pleased with is the test. It’s easy to write a unit test that mocks a Redis error; it’s much more convincing to simulate the actual failure. The integration test uses <code class="language-plaintext highlighter-rouge">iptables</code> to <strong>REJECT</strong> traffic to Redis mid-run — a real network partition, the same thing a Dragonfly redeploy looks like from the process’s point of view — and asserts that the <code class="language-plaintext highlighter-rouge">MutexOwner</code> survives the outage and recovers when the rule is dropped. That’s the difference between “handles a mocked error” and “survives the thing that was actually paging me.”</p>

<p>The outages stopped.</p>

<h2 id="then-it-went-behind-cloudflare-access">Then it went behind Cloudflare Access</h2>

<p>Once you own a fork, you stop just patching it and start shaping it. The most recent addition: real SSO on the admin console.</p>

<p>Out of the box, self-hosted Sequin authenticates users with an email-and-password login sitting in its own Postgres table. For an internal tool that a whole team touches, that’s the wrong model — there’s already Google identity and <a href="https://www.cloudflare.com/zero-trust/">Cloudflare Zero Trust</a> in front of everything else. So the Sequin console went behind <strong>Cloudflare Access</strong>, and Sequin learned to trust it.</p>

<p>The mechanics, mirroring the same trusted-header pattern I’ve used for a <a href="https://payloadcms.com">Payload CMS</a>:</p>

<ul>
  <li><strong>Cloudflare Access</strong> gates the console with a Google-SSO policy. On every request it forwards, it injects a signed JWT in the <code class="language-plaintext highlighter-rouge">Cf-Access-Jwt-Assertion</code> header.</li>
  <li>A new Elixir plug <strong>verifies that JWT</strong> — fetching the Access application’s public keys (JWKS), checking the signature, issuer, audience, and expiry — and then transparently signs the user in. First time through, it <strong>provisions the user just-in-time</strong> from the verified email, adopting any existing account so nobody lands in an empty instance. You never see Sequin’s own login screen.</li>
  <li>The tricky part is machine traffic. The search config is applied from CI, which authenticates with a token rather than a browser SSO session. Cloudflare Access lets you scope policies so interactive console traffic goes through Google while automated, token-authenticated callers are validated on their own credentials — so the SSO gate never breaks the deploy pipeline.</li>
  <li>Finally, the settings UI now reflects reality: it shows which identity provider you authenticated with and disables the email/password fields for SSO users, rather than presenting a dead form.</li>
</ul>

<p>The whole thing is feature-flagged, so the upstream password-login behaviour is still the default for anyone else running the code. It ships as a normal patched image through the same GHCR-to-Railway pipeline as every other fix.</p>

<h2 id="owning-a-fork-you-didnt-write">Owning a fork you didn’t write</h2>

<p>There’s a version of this story that reads as a cautionary tale about depending on startups. I don’t think that’s the lesson. Sequin was — is — a genuinely good piece of engineering, and the fact that it <em>could</em> be adopted, read, fixed, and extended with SSO is entirely because it was open source. A closed SaaS that shut down would have left nothing but a migration deadline.</p>

<p>The real lesson is that “using open source” and “owning open source” are different commitments, and the gap between them can close overnight. When it does, the codebases you can actually take over are the ones written clearly enough to understand under pressure. Sequin was. You read the supervision tree, see why a Redis blip cascaded, and fix it — in someone else’s code that has quietly become yours.</p>

<p>If you’re travelling Australia or New Zealand, the search that lands you at the right campsite is riding on this pipeline. <a href="https://apps.apple.com/app/campermate/id578975305">Grab CamperMate on iOS</a> or <a href="https://play.google.com/store/apps/details?id=nz.co.campermate.app">Android</a> — free, no account required.</p>

<hr />

<p><em>Header photo by <a href="https://unsplash.com/@v2osk">v2osk</a> on <a href="https://unsplash.com">Unsplash</a>.</em></p>]]></content><author><name>Isaac Rowntree</name></author><category term="engineering" /><category term="sequin" /><category term="cdc" /><category term="change-data-capture" /><category term="postgres" /><category term="elixir" /><category term="redis" /><category term="dragonfly" /><category term="cloudflare-access" /><category term="railway" /><category term="open-source" /><category term="campermate" /><category term="typesense" /><summary type="html"><![CDATA[Adopting Sequin — an open-source Postgres change-data-capture engine — after the company behind it wound down: what I use it for, the Redis mutex bug that let a Dragonfly redeploy take the whole thing down, the fix, and adding Cloudflare Access SSO to a fork I now help maintain.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/taking-over-sequin.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/taking-over-sequin.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>