<?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-28T01:57:47+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">Which pin did you tap? Building a test rig that reads the screen, because three map SDKs would not tell us</title><link href="https://zackdesign.biz/which-pin-did-you-tap/" rel="alternate" type="text/html" title="Which pin did you tap? Building a test rig that reads the screen, because three map SDKs would not tell us" /><published>2026-09-18T00:00:00+00:00</published><updated>2026-09-18T00:00:00+00:00</updated><id>https://zackdesign.biz/which-pin-did-you-tap</id><content type="html" xml:base="https://zackdesign.biz/which-pin-did-you-tap/"><![CDATA[<p><a href="https://campermate.com">CamperMate</a> is a map. Its users are looking for a campsite in a pack of forty pins around Queenstown, and the whole product is the tap that opens the right one. From the first React Native build, that tap was wrong in a way we could describe but not explain: on iOS, in a dense pack, the pin you got was the one next to the one you touched. On Android it was the one underneath. On the website, on Mapbox, a finger in the empty corner beside one pin’s pointer opened a different pin whose head was under that corner. We had followed the documentation for <code class="language-plaintext highlighter-rouge">react-native-maps</code>, for MapKit, for the Google Maps SDK and for Mapbox GL. None of it was wrong exactly. None of it described what the SDK hit-tests.</p>

<p>We had lived with it for a year. What changed was that we started using Claude Fable 5.1 on the codebase and had it read the SDK sources instead of the docs, and then build something we had never had: a test rig on each platform that puts real fingers on real pins and reads the pixels back to see what actually happened. That rig is the story. Our own theories about the bug were wrong twice, and it was the rig, not the reasoning, that said so. Along the way it gave us a tap rule in JavaScript that decides against the pin as drawn and in the order it is painted, three bugs in <code class="language-plaintext highlighter-rouge">react-native-maps</code> on the new architecture, an Android tap that the app now hears about 22 ms after the finger lifts instead of 649 ms, and, when the same rule was ported to the website’s Mapbox map, a warning that our port had got draw order wrong while every test passed.</p>

<!-- more -->

<h2 id="the-question-the-sdks-do-not-answer">The question the SDKs do not answer</h2>

<p>The app’s map is <code class="language-plaintext highlighter-rouge">react-native-maps</code> 1.29 on Fabric, React Native’s newer rendering layer. On iOS the map is MapKit and every pin is a live React Native view inside an <code class="language-plaintext highlighter-rouge">MKAnnotationView</code>, MapKit’s container for a pin. On Android it is Google Maps and every pin is a bitmap, drawn natively from the same geometry so the two look identical. The website is <code class="language-plaintext highlighter-rouge">mapbox-gl</code> 3.29, with the pins drawn by Mapbox itself from a list of points (a symbol layer over a GeoJSON source).</p>

<p>When a finger lands on a cluster, the app needs one answer: which pin did the user put their finger on? Each SDK answers a different question.</p>

<ul>
  <li><strong>MapKit</strong> tests the whole rectangle the pin is drawn in. A pin is a circle on a pointer, so most of that rectangle is empty space either side of the pointer. Tap the empty corner beside pin A’s pointer and MapKit reports A, even when your finger is visibly on the head of pin B behind it.</li>
  <li><strong>Google Maps</strong> tests a zone that does not line up with the pin at all. For custom marker images the zone is bigger than the image and shifted upward, a long-standing SDK defect that is worse on real phones than on the emulator. Measured later on a Samsung A13 with real touches: a finger placed dead-centre on pin <em>b</em> was reported as the pin beside it, and a tap on empty map well above a pin counted as a tap on that pin.</li>
  <li><strong>Mapbox GL</strong> tests the rectangle it uses to stop labels overlapping, the “collision box”. For our pins that is the whole image the pin is drawn in, including the transparent margin reserved for badges, so the clickable area is half as wide again as the pin you can see.</li>
</ul>

<p><img src="/images/blog/marker-hit-three-failures.svg" alt="Three panels. MapKit: a finger on pin B's head, inside pin A's rectangular frame, selects A. Google Maps: a finger dead-centre on pin b is named c, because c's hit region is offset upward over b. Mapbox: a finger in the empty corner of pin A's collision rectangle, over pin B's head, selects A." /></p>

<p>Three SDKs, three rectangles. The <code class="language-plaintext highlighter-rouge">react-native-maps</code> documentation says a marker has an <code class="language-plaintext highlighter-rouge">onPress</code>. MapKit’s says an annotation view is selected on tap. Google’s says <code class="language-plaintext highlighter-rouge">onMarkerClick</code> fires for the marker that was clicked. Mapbox’s says <code class="language-plaintext highlighter-rouge">queryRenderedFeatures</code> returns the features at a point. All true. All silent on the shape being tested. That silence was the whole bug.</p>

<h2 id="deciding-it-ourselves">Deciding it ourselves</h2>

<p>The fix is the same on every platform: stop asking the SDK which pin, ask it where the finger was, project the nearby pins to the screen, and decide against the shape we actually draw, in the order we actually paint.</p>

<p>That needs two things the SDKs do not hand over by default: the finger position and an agreed shape.</p>

<p><strong>The finger position.</strong> On Android, the library’s marker-click event reports the marker’s position, not the tap’s. The patch reports the tap location instead, which the touch handler already captures, the way presses on polygons and lines already do. On iOS it took two small patches, both upstream bugs on the Fabric path:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">RNMapsMarkerView.mm</code> built the press event’s position by reading an <code class="language-plaintext highlighter-rouge">x</code> and a <code class="language-plaintext highlighter-rouge">y</code> out of a dictionary that only holds latitude and longitude. There is no <code class="language-plaintext highlighter-rouge">x</code> or <code class="language-plaintext highlighter-rouge">y</code> in it, so every marker press on the new architecture reported its position as zero, zero: the top-left corner of the screen.</li>
  <li><code class="language-plaintext highlighter-rouge">AIRMapMarker.m</code> measured the finger relative to the pin’s callout view. With no callout open that view does not exist, which also gives zero, zero. It now measures the finger relative to the map (<code class="language-plaintext highlighter-rouge">[recognizer locationInView:marker.map]</code>).</li>
</ul>

<p>On the web the position is free: the click event has it. Mapbox is only asked for the list of pins drawn in a box around the click, a little bigger than a pin so that a pin whose edge is near the click is included, and the decision is made from there.</p>

<p><strong>The shape.</strong> One small file describing the pin as a circle sitting on a pointer, every measurement taken from the tip, which is the point that sits on the map coordinate:</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">"circleCenterAboveTip"</span><span class="p">:</span><span class="w"> </span><span class="mi">26</span><span class="p">,</span><span class="w">
  </span><span class="nl">"circleRadius"</span><span class="p">:</span><span class="w"> </span><span class="mi">15</span><span class="p">,</span><span class="w">
  </span><span class="nl">"pointerTopAboveTip"</span><span class="p">:</span><span class="w"> </span><span class="mi">17</span><span class="p">,</span><span class="w">
  </span><span class="nl">"pointerHalfWidth"</span><span class="p">:</span><span class="w"> </span><span class="mi">12</span><span class="p">,</span><span class="w">
  </span><span class="nl">"containerWidth"</span><span class="p">:</span><span class="w"> </span><span class="mi">40</span><span class="p">,</span><span class="w">
  </span><span class="nl">"containerHeight"</span><span class="p">:</span><span class="w"> </span><span class="mi">45</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The JavaScript hit test reads it. The Android code that draws the pin is tested against it pixel by pixel. The iOS view is rendered into a bitmap and compared to it. The website’s <code class="language-plaintext highlighter-rouge">pinGeometry.json</code> now carries it verbatim, and a browser test (Playwright) draws the web pin on a real canvas and checks every pixel’s transparency against the same shape rule. The file exists because the first version of the hit test shipped with a hand-copied rectangle describing a pin the app no longer drew: the renderer had moved the circle two pixels and nothing tied the two together. Now they cannot disagree without a test failing.</p>

<p>The silhouette test is small:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">isInsideMarker</span><span class="p">(</span><span class="nx">dx</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span> <span class="nx">dy</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span> <span class="nx">g</span> <span class="o">=</span> <span class="nx">MARKER_PIN_GEOMETRY</span><span class="p">):</span> <span class="nx">boolean</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">fromCentreY</span> <span class="o">=</span> <span class="nx">dy</span> <span class="o">+</span> <span class="nx">g</span><span class="p">.</span><span class="nx">circleCenterAboveTip</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">dx</span> <span class="o">*</span> <span class="nx">dx</span> <span class="o">+</span> <span class="nx">fromCentreY</span> <span class="o">*</span> <span class="nx">fromCentreY</span> <span class="o">&lt;=</span> <span class="nx">g</span><span class="p">.</span><span class="nx">circleRadius</span> <span class="o">*</span> <span class="nx">g</span><span class="p">.</span><span class="nx">circleRadius</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="kc">true</span>
  <span class="p">}</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">dy</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="nx">dy</span> <span class="o">&gt;=</span> <span class="o">-</span><span class="nx">g</span><span class="p">.</span><span class="nx">pointerTopAboveTip</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">taper</span> <span class="o">=</span> <span class="o">-</span><span class="nx">dy</span> <span class="o">/</span> <span class="nx">g</span><span class="p">.</span><span class="nx">pointerTopAboveTip</span> <span class="c1">// 1 at the top edge, 0 at the tip</span>
    <span class="k">return</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">abs</span><span class="p">(</span><span class="nx">dx</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="nx">g</span><span class="p">.</span><span class="nx">pointerHalfWidth</span> <span class="o">*</span> <span class="nx">taper</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="kc">false</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A circle plus a tapering pointer, not a rectangle drawn around the whole thing. A rectangle is exactly how you get “the touch target is wider than the marker”.</p>

<p>The other half of the rule is which pin wins when silhouettes overlap. The original code picked the nearest tip. That is structurally wrong: a pin’s circle sits about 26 pixels above its own tip, so when you tap the circle you can see, you lose to whichever pin has its tip under your finger, and that pin’s circle is usually hidden behind the one you aimed at. That was Android’s “selects the one underneath” in its entirety. The rule is now: among pins whose silhouette contains the tap, the one painted on top wins, because that is the one the user can see. What decides “on top” is the search itself: every place comes back from search with a relevance score, the more relevant place is drawn over the less relevant one, and the same number that sets the drawing order is the number the tap rule reads. If those two ever disagreed, the user would tap the pin they can see and get the one behind it.</p>

<p><img src="/images/blog/marker-paint-order-rule.svg" alt="Two overlapping pins. The finger is on the visible head of the front pin, but the back pin's tip lies under the finger. Nearest-tip picks the back pin, whose head is hidden. Painted-on-top picks the front pin, the one the user touched." /> If the finger misses every pin outline, the nearest pin within about twelve pixels wins. That allowance exists because a fingertip is about 7 mm wide and the pointer is only three pixels wide near its tip, so a tap that is clearly at a pin often lands just outside its outline.</p>

<h2 id="the-first-version-made-it-worse">The first version made it worse</h2>

<p>The first build with the new rule went out to a phone and came back with one line: <em>“categorically worse behaviour.”</em> Pins with nothing near them opened a different pin on Android, and some pins on iOS stopped responding at all. The rule was right. Four things it depended on were not.</p>

<table>
  <thead>
    <tr>
      <th>What the user saw</th>
      <th>What was actually happening</th>
      <th>What fixed it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Android: a lone pin opens a different place</td>
      <td>Two places sat at the same coordinate. We assumed Google draws them in the order we sent them; Google’s documentation says the order is arbitrary, and it was. Our rule picked the one we thought was on top, Google had drawn the other one on top.</td>
      <td>Every pin gets its own stacking number, built from its search relevance with its position in the list as the tiebreak, and both the drawing and the tap rule use that same number.</td>
    </tr>
    <tr>
      <td>iOS: the same, in dense packs</td>
      <td>Our stacking numbers had a fraction in them to keep them unique. iOS stores that number as a whole number, so the fraction was silently thrown away and pins tied again.</td>
      <td>Whole numbers only.</td>
    </tr>
    <tr>
      <td>iOS: a pin lights up and immediately goes dark</td>
      <td>iOS reported each tap twice, once from the pin and once from the map. The second report deselected what the first had selected.</td>
      <td>The first report of a tap wins; any repeat at the same spot within half a second is ignored.</td>
    </tr>
    <tr>
      <td>iOS: some pins ignore taps</td>
      <td>A finger just outside a pin’s outline used to be handled by MapKit’s rectangle. We had replaced that with nothing.</td>
      <td>The nearest pin within about twelve pixels wins, which is also how a fingertip 7 mm wide actually lands.</td>
    </tr>
  </tbody>
</table>

<p>None of these were visible in the unit tests, because the unit tests encoded the same assumptions we had. Every test we could write would agree with us. We needed a judge that could not: the pixels on the screen, and real touches, on the real app.</p>

<h2 id="the-test-rig-trust-nothing-but-the-screen">The test rig: trust nothing but the screen</h2>

<p>The ask was explicit: <em>“set up a test harness against native code and JS code together without needing a sim.”</em> The requirement underneath that message is the one that matters. Every layer between our code and the screen had an opinion about what a tap meant, and every one of them had been wrong at some point. The rig had to run the real app, use the real SDK, put a real finger down, and take its answer from the only place none of those layers could edit: the screenshot.</p>

<p>It ended up as four layers of tests, each catching something the one below could not.</p>

<p><strong>1. Draw the Android pin on a laptop and check every pixel.</strong> No phone, no emulator. The code that draws the Android pin is run on an ordinary computer using Robolectric, a tool that lets Android code run outside a phone, and the result is compared pixel by pixel with the shape file at the three screen densities our test phones have. A second test goes further: it saves the drawn pins as images and runs the tap rule itself over them, so the code that draws and the code that decides are checked against each other in one place.</p>

<p><strong>2. Run Apple’s real map code inside the real app.</strong> Apple’s map classes, and the layer of <code class="language-plaintext highlighter-rouge">react-native-maps</code> that sits on them, cannot be tested on their own; they only compile as part of the app. So the iOS tests live in a separate project that loads the actual CamperMate build and drives its map objects directly: create a pin, remove it, reuse it, tap it, ask where its frame is. Three more tests go one level up and mount a second, test-only screen inside the running app’s own React Native instance, then read back what JavaScript received. Every patch we made to the SDK is checked by undoing it and confirming exactly one test fails.</p>

<p><strong>3. Put real fingers on real pins, where the test says they are.</strong> On iOS a test places three pins on the live map, writes their on-screen positions to a file, and waits. A script reads the file and uses Maestro, a tool that taps a phone’s screen from a script, to tap the map above pin <em>a</em>, the centre of pin <em>b</em>, and the empty corner beside pin <em>c</em>. The test then checks what JavaScript was told. One thing this taught us: touches never arrive at a test window unless it has a root view controller, the iOS object that owns what a window shows.</p>

<p><strong>4. Real fingers on Android, inside the running app, judged by a screenshot.</strong> An Android test mounts the same test-only screen inside the app, works out where each pin’s image is from Google’s projection and the pin’s anchor, and injects real touches at those points. Android has no way to ask which pin it drew on top, so the test reads the screen: it takes a screenshot, looks at the pixel at the centre of the selected pin, and checks that it is the brand yellow before and after the tap. Three things this taught us: the standard automation click never reaches this window, the app has no keyboard focus for about ten seconds after the screen mounts, and two touches on one spot within 0.7 s count as a double-tap zoom.</p>

<p>Asked at the end of that day whether all this was “a sad indictment” of the developer experience, the summary we wrote down was: three layers of software that never agreed on what a tap meant, and the only description of the truth was the pixels on the screen.</p>

<h2 id="what-the-screen-showed">What the screen showed</h2>

<p>The rig disproved our best theory and found the real one. That sentence is the reason this post exists, so here is exactly how it happened.</p>

<p>The report was vivid: <em>“I’m literally clicking one POI a very long way away and an iOS marker on the other side of a mountain range turns on.”</em> The theory was view recycling: Fabric reuses the native view behind a pin for a different pin, and if the old pin’s annotation was still on the map, a tap on it would be reported under the new pin’s name. We patched the recycling hook, <code class="language-plaintext highlighter-rouge">prepareForRecycle</code>, to disconnect the old annotation and take it off the map. Plausible, checked by reverting it and watching a test fail, and wrong. A test that creates and destroys pins twelve times over through the real renderer, with that patch reverted, left no stale annotations behind. The patch stays in as a precaution.</p>

<p>The test that checks where each pin’s frame sits found the actual bug. <strong>On Apple Maps the <code class="language-plaintext highlighter-rouge">anchor</code> prop does nothing, and the docs do say so in one line we had never read: “iOS: Google Maps only. For Apple Maps, see the <code class="language-plaintext highlighter-rouge">centerOffset</code> prop.” We had passed <code class="language-plaintext highlighter-rouge">anchor</code> on both platforms for a year and nothing at runtime warned.</strong> We had asked for the pin to hang from its bottom centre (<code class="language-plaintext highlighter-rouge">anchor {0.5, 1}</code>). The Fabric marker in <code class="language-plaintext highlighter-rouge">react-native-maps</code> ignores that and reads only <code class="language-plaintext highlighter-rouge">centerOffset</code>, which was left at zero, so every pin, 40 points wide and 45 tall, sat centred on its coordinate with its tip drawn 22.5 points too low. The JavaScript hit test assumed the tip was on the coordinate, so the shape it checked floated 22.5 points above the pin the user could see. Empty map above a pin selected it. A tap on the drawn circle mostly missed and fell through to the nearest-pin fallback. The info window’s iOS lift had been “tuned on device” against those low-drawn pins. The fix is one constant, <code class="language-plaintext highlighter-rouge">{0, -containerHeight / 2}</code>: shift the view up by half its own height. Every iOS pin moved up to where its coordinate is, in line with Android.</p>

<p><img src="/images/blog/marker-anchor-drift.svg" alt="Before: the iOS pin is drawn centred on its coordinate, so its tip is 22.5 points too low, while the invisible shape the hit test checks has its tip on the coordinate and floats above the drawn pin. After: the two coincide." /></p>

<p>Other findings, in the order the screen gave them up:</p>

<ul>
  <li><strong>Every pin blinked on every selection (iOS).</strong> A frame-difference filter over a cluster far from the tapped pin showed every other pin blanked for two frames. Fabric treats a change to a pin’s stacking number (<code class="language-plaintext highlighter-rouge">zIndex</code>) as a reorder of siblings, which it does by removing and re-inserting the view, and MapKit lays out every annotation again on any add or remove. Raising the selected pin by 1,000 instead of 999,999 flashed just the same. A separate overlay marker mounted on top did not. Base pins now never change props when selected; the highlight is its own marker on both platforms.</li>
  <li><strong>The peek took forever to disappear.</strong> JavaScript dismissed it within 50 ms of the map reporting a press. The wait came before that: on both platforms the single-tap recogniser waits to see whether a second tap follows before it reports the first. The peek now hides the instant a finger touches the map, and the selection clears once the tap is confirmed.</li>
  <li><strong>The invisible strip under the card.</strong> The tappable area of the info window included an invisible strip, the full width of the card, between the card and the pin, so tapping the gap opened the place on both platforms. The tappable area is now the card itself.</li>
  <li><strong>On Android, showing an info window overrides the stacking order.</strong> <code class="language-plaintext highlighter-rouge">react-native-maps</code> calls <code class="language-plaintext highlighter-rouge">showInfoWindow</code> on every tapped marker when <code class="language-plaintext highlighter-rouge">moveOnMarkerPress</code> is off, and Google draws the marker whose window is showing above every other marker whatever its stacking number. An empty window still counts. That is why the selection overlay had been rendering perfectly and invisibly, underneath. The patch shows a window only for a marker that has one; proven by reading the pixel at the circle centre.</li>
  <li><strong>A tap on empty sea opened a random POI (Android).</strong> When the finger missed every pin outline and no pin was within the twelve-pixel allowance, the code still selected whichever marker Google had named. Android’s finger is exact now, so a miss there is a background tap. iOS keeps its fallback because MapKit’s frame is at least the pin’s own box.</li>
  <li><strong>An invisible dev-only control.</strong> A 96-by-72-pixel button for triggering end-to-end tests rendered in every dev build, transparent, in the left-middle of the map, eating taps. “This popup broke marker clicks” was true and the popup was innocent.</li>
</ul>

<h2 id="dense-packs-with-real-data">Dense packs, with real data</h2>

<p>Three pins on a test screen is not what users see. Around every popular tourism town, Queenstown, Wellington, Byron Bay, CamperMate shows a dense pack of pins all the time, and that is where the wrong-pin bug lived. So the tests use the real data: a script pulled the 250 places nearest to each of six such towns, 1,266 in all, straight from the production search index. A test lays each scene out on a phone screen at four zoom levels, in the app’s paint order, then simulates a tap every three pixels across the whole pack, tens of thousands of taps per scene. The hit test must return the pin painted at every pixel; the geographic pre-filter must never drop it; every pin with any ink must be tappable; one tap over the densest pack stays under 2 ms.</p>

<p>It found a real flaw on its first run. The nearest-pin fallback kept a running best and let near-ties drift along a chain, B to C to A, each within the tie band of the last, until it could answer a pin farther than the nearest, or one beyond the twelve-pixel allowance entirely. Ties are now relative to the minimum distance and the slop is a hard limit on the answer.</p>

<p>On device, 160 real Wellington places at suburb zoom with four real touches: iOS four of four, Android four of four, each checked against the pin the operating system actually painted on top. On iOS that is decided by each layer’s stacking position; the system’s own hit-testing walks the view tree in a different order, so it cannot be used as the answer key.</p>

<h2 id="then-android-was-correct-and-slow">Then Android was correct and slow</h2>

<p>With both platforms resolving the right pin, the next report was about feel: <em>“on Android it’s nowhere near as fast as iOS when switching between info windows. Noticeably bad. There’s a huge lag.”</em></p>

<p>Every Android tap was waiting on Google. When a finger lifts, the Google Maps SDK holds the event for 300 ms to see whether a second tap follows, because two taps mean zoom. Only then does it run its own marker hit test and call the app. iOS pins have their own tap recogniser and never waited. Measured on the arm64 emulator, from the moment the finger touched the screen to the moment the app’s JavaScript heard about it:</p>

<table>
  <thead>
    <tr>
      <th>tap</th>
      <th>before</th>
      <th>after</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>empty map</td>
      <td>295 ms</td>
      <td>21 ms</td>
    </tr>
    <tr>
      <td>centre of pin <em>b</em></td>
      <td>649 ms</td>
      <td>22 ms</td>
    </tr>
    <tr>
      <td>dead corner of pin <em>c</em></td>
      <td>709 ms</td>
      <td>22 ms</td>
    </tr>
  </tbody>
</table>

<p>The patch listens for the finger lifting on the same gesture detector that already sees every touch before Google does, and tells the app right then, with the finger position. Google’s own callbacks for that same lift are then ignored, using a flag set when the fast path fires and cleared when the next finger comes down. A long press or a drag Google consumed is never also a tap. The tap’s screen position is converted to a map position once and reused, where before it crossed between native code and JavaScript three times in a row.</p>

<p>The same change made Android pins fade in over 250 ms once their bitmap lands, which also hides Google’s red default pin while a cold bitmap decodes. The old native app had the same entrance.</p>

<p>Code review caught what the harness had not: a pin whose image failed to load could be left fully transparent, invisible but still tappable, so both failure paths now fall back to Google’s default pin. And Google’s My Location button became a map press in the location picker, dropping the pin under the button; the screen has its own button, so Google’s is off.</p>

<p>One accepted behaviour change: a double-tap to zoom with a peek open now closes the peek on the first tap. Same as iOS, new on Android.</p>

<p><img src="/images/blog/marker-tap-timeline.svg" alt="Two timelines of an Android tap. Before: finger down, finger up, Google waits 300 ms to rule out a double tap and runs its own hit test, and the app hears about the tap 649 ms after the finger went down. After: the app is told 22 ms after the finger lifts, and Google's own callbacks for the same tap are ignored." /></p>

<h2 id="the-website-had-the-same-bug-and-two-of-its-own">The website had the same bug, and two of its own</h2>

<p>The website’s map had the identical complaint, and the ask was direct: <em>“look at how we did marker hit box and cleanup fixes for 5.6.1 in campermate-react-native. we are struggling with the same tap targets on mapbox in mobile / desktop views on campermate.com.”</em> Followed three minutes later by: <em>“clicking on different markers shows the infowindow photo for the previous marker.”</em></p>

<p>Reading <code class="language-plaintext highlighter-rouge">mapbox-gl</code> 3.29 rather than its docs produced the same class of defect. Mapbox resolves a symbol-layer click against the icon’s collision rectangle, the whole padded canvas including the transparent badge margins, so a tap in the dead corner of one pin’s box over the visible head of a lower pin selected the wrong pin. Worse, two handlers answered every click: the layer’s handler set the selection, then a map-level handler re-queried one pixel and cleared it if the query disagreed. A finger 5 px off a 36 px head was a background click that closed the panel. The stale photo was simpler: the panel’s image element had no React <code class="language-plaintext highlighter-rouge">key</code>, so React reused the same element and only changed its address, and the browser kept painting the previous place’s photo until the new one downloaded.</p>

<p>The port was almost verbatim. <code class="language-plaintext highlighter-rouge">lib/Maps/markerHitTest.ts</code> on the web is the app’s hit test; <code class="language-plaintext highlighter-rouge">pinGeometry.json</code> started as a model of the app’s geometry and, a day later, became a copy of it, with the app’s Android rasteriser constants alongside so the web pin is the app’s pin to the pixel. One click handler owns the click. Mapbox lists the pins drawn in a box around the click, each is converted to a screen position, and the same rule decides: the pin whose outline holds the click, topmost first, then the nearest pin within about twelve pixels, otherwise the click was on the background, exactly as on Android, because a mouse pointer is exact. The dense-pack sweep was ported with its six fixtures. A Playwright test bundles the real renderer, draws the pin on a real canvas and checks every pixel’s transparency against the shape rule, the web’s counterpart of the Android laptop test. Another drives the real desktop map with a mouse: on the Queenstown pack there are 44 points where Mapbox’s rectangle names the wrong pin and the silhouette names the right one.</p>

<p>Then the web had two surprises the apps did not.</p>

<p><strong>Mapbox’s query order is not its paint order.</strong> The first version trusted the order <code class="language-plaintext highlighter-rouge">queryRenderedFeatures</code> returns as the draw order, and the PR description said so, “verified in the 3.29 source”. That holds only for layers Mapbox sorts by screen position, top to bottom. With a sort key set, which the pin layer uses so that the same search relevance as in the app decides what draws on top, <code class="language-plaintext highlighter-rouge">mapbox-gl</code> never records its draw order at all and returns results in the reverse of the order the data arrived, unrelated to what it drew. So the wrong pin could still win whenever results arrived most-relevant first, which is the common case, and the test’s answer key shared the assumption, which is why everything passed. Paint order now comes from each feature’s own sort key with the query index as the source-order tiebreak, the test’s answer key applies that rule independently, and the pointer cursor is driven by the same resolver, because until then the cursor promised a click on the rectangle’s corners that the silhouette then read as background.</p>

<p><strong><code class="language-plaintext highlighter-rouge">map.project</code> returns one world copy.</strong> A pin drawn on a wrapped copy of the world, any low zoom with copies rendered or a Chatham Islands viewport, projected a world away from the click and read as background. Each candidate is now projected three times, at its own longitude and one full world width to the east and to the west, and the copy nearest the click is kept. The info window’s own placement had the same seam bug, fixed by reusing the helper.</p>

<p>The port also exposed a regression nobody had noticed: the info window’s tail, the small triangle pointing at the pin, had disappeared in April when the card stopped being part of a DOM marker and became an absolutely positioned element lifted a flat 18 px, over the head of its own pin. Tapping pins had been unreliable enough that nobody saw where the card landed. The tail is back, the lift comes from the shared geometry, and the placement rule is the app’s: above, below when there is no room, beside at the edges, tail always on the pin.</p>

<p>One thing did not port. <code class="language-plaintext highlighter-rouge">icon-size</code> is a layout property in Mapbox, not a per-feature one, so the app’s iOS sprout animation is not available on a symbol layer. Opacity is per feature, so the web pins fade in over the app’s 150 ms, Android’s entrance.</p>

<h2 id="would-this-have-been-easier-without-react-native-in-the-way">Would this have been easier without React Native in the way?</h2>

<p>A fair question, because the six upstream issues below are all <code class="language-plaintext highlighter-rouge">react-native-maps</code> bugs, and it would be easy to read this post as “React Native made maps hard”. The honest split is about a third and two thirds.</p>

<p><strong>The part the SDKs own, which native would have hit just the same.</strong> The CamperMate apps that came before React Native used the Google Maps SDK on both platforms, and both took the SDK’s answer as the answer: the iOS app’s <code class="language-plaintext highlighter-rouge">didTapMarker:</code> and the Android app’s <code class="language-plaintext highlighter-rouge">OnMarkerClickListener</code> each opened whichever marker Google named, with no hit test of their own. So the Google Maps hit zone, bigger than the icon and shifted upward, was in those apps for years. Google’s 300 ms wait before reporting a single tap, to rule out a double tap, was there too, since the native apps waited on the same callback. Arbitrary draw order for two markers at one coordinate, <code class="language-plaintext highlighter-rouge">showInfoWindow</code> pulling a marker above every stacking number, and the fact that no SDK will tell you which marker it painted on top: all of that is the SDK, and a native app that wanted the right pin would have needed the same silhouette, the same paint-order rule, the same finger-up dispatch, and a screenshot to check its work.</p>

<p><strong>The part React Native added.</strong> Every bug in the “finger position” half of the fix was the bridge: a press position read out of a dictionary that only holds latitude and longitude, a finger measured against a callout view that did not exist, Android reporting the marker’s position instead of the finger’s. A native app gets the touch point from the gesture recogniser or the motion event and never has this problem. <code class="language-plaintext highlighter-rouge">anchor</code> doing nothing on Apple Maps is a leak in an abstraction; the old iOS app set <code class="language-plaintext highlighter-rouge">groundAnchor</code> itself, in one line. Whole-number stacking numbers, the annotation remount and blink when one pin’s <code class="language-plaintext highlighter-rouge">zIndex</code> changes, the tap reported twice: all artefacts of the layer in the middle. And most of the days went on the rig, because with three layers between the finger and the SDK, the only way to test was to mount a screen inside the running app and inject touches from outside it. Natively that is an ordinary XCTest or Espresso test with the views in hand.</p>

<p>One caveat cuts the other way. Native would have made it easier to not notice. The hit-test defect was tolerable in the native apps for years precisely because there was no framework amplifying it into “a marker on the other side of a mountain range turns on”. React Native made it bad enough to force the fix; the fix itself, silhouette plus paint order plus finger position, is the same on every platform and would have been worth doing natively too.</p>

<h2 id="what-we-would-tell-someone-starting-this">What we would tell someone starting this</h2>

<ul>
  <li><strong>Build the rig before the theory.</strong> Every explanation we reasoned our way to was wrong, including one we had already patched and mutation-tested. The test that took a screenshot and looked at a pixel was right every time, and the one web test that shared our assumption instead of checking the screen passed for a day while being wrong. When the layers below you disagree with each other, the screen is the only witness they cannot argue with.</li>
  <li><strong>No map SDK answers “which pin did I tap”.</strong> MapKit tests a frame, Google tests an offset region, Mapbox tests a collision rectangle. Their documentation describes the event, not the shape. Read the source. Then decide it yourself, from the finger position, against the shape you draw, in the order you paint.</li>
  <li><strong>Put the pin’s geometry in one file</strong> and test every renderer against it, pixel by pixel. Our first hit box described a pin nobody drew any more.</li>
  <li><strong>Paint order is part of the hit test.</strong> Make it unique, make it an integer, read it from the same field on both sides, and never take a query’s result order as the draw order without checking which sort the layer uses.</li>
  <li><strong>On Apple Maps, <code class="language-plaintext highlighter-rouge">anchor</code> does nothing and <code class="language-plaintext highlighter-rouge">centerOffset</code> is the whole story.</strong> The docs say it in one line; nothing at runtime does. Changing <code class="language-plaintext highlighter-rouge">zIndex</code> remounts the annotation and lays out every other one again. Highlight with a separate marker.</li>
  <li><strong>Mutation-test the patches.</strong> A hunk that stays green when reverted is decoration. One of ours was.</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">react-native-maps</code> changes that are general rather than CamperMate-specific are now filed upstream with their diffs: the Fabric press position and the nil callout view (<a href="https://github.com/react-native-maps/react-native-maps/issues/6002">#6002</a>), the callout z-position boost that never applied (<a href="https://github.com/react-native-maps/react-native-maps/issues/6003">#6003</a>), the blink when one pin’s <code class="language-plaintext highlighter-rouge">zIndex</code> changes (<a href="https://github.com/react-native-maps/react-native-maps/issues/6004">#6004</a>), Android reporting the marker’s position instead of the finger’s (<a href="https://github.com/react-native-maps/react-native-maps/issues/6005">#6005</a>), <code class="language-plaintext highlighter-rouge">showInfoWindow</code> raising pins above every <code class="language-plaintext highlighter-rouge">zIndex</code> (<a href="https://github.com/react-native-maps/react-native-maps/issues/6006">#6006</a>), and the finger-up dispatch with its latency table (<a href="https://github.com/react-native-maps/react-native-maps/issues/6007">#6007</a>). The Mapbox finding went onto the existing report of query order ignoring <code class="language-plaintext highlighter-rouge">symbol-sort-key</code> (<a href="https://github.com/mapbox/mapbox-gl-js/issues/10800#issuecomment-5722789154">mapbox-gl-js #10800</a>). Everything else here is the shape of the work: a year of following the docs, then a few days of reading what the SDKs actually do and building something that could tell us when we were wrong.</p>]]></content><author><name>Isaac Rowntree</name></author><category term="engineering" /><category term="react-native" /><category term="react-native-maps" /><category term="mapkit" /><category term="google-maps" /><category term="mapbox" /><category term="fabric" /><category term="hit-testing" /><category term="testing" /><category term="xctest" /><category term="robolectric" /><category term="playwright" /><category term="campermate" /><summary type="html"><![CDATA[CamperMate's map markers selected the wrong pin from day one, on iOS, Android and the web, and every SDK's documentation described a tap without describing what it tested. The way out was a test rig on each platform that puts real fingers on real pins and reads the pixels back. It disproved our best theory in an afternoon, found the real bug, caught three upstream defects, measured a 30× cut in Android tap latency, and on the web caught our own port getting draw order wrong.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zackdesign.biz/images/blog/marker-hit-regions.jpg" /><media:content medium="image" url="https://zackdesign.biz/images/blog/marker-hit-regions.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><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 set of <code class="language-plaintext highlighter-rouge">make</code> targets that script VESC Tool, the desktop program for configuring a <strong>VESC</strong> motor controller (an open-source electronic speed controller, ESC, used in electric skateboards and bikes), over the phone app’s Bluetooth bridge. That lets a controller sealed inside a skateboard deck be read, written and verified from a laptop with no USB cable. It exists because a FOCBOX Unity, a two-motor VESC, flashed to firmware 7.00 stopped talking to the DAVEGA X, the small display bolted to the deck, over two bytes: the telemetry packet the display draws (<code class="language-plaintext highlighter-rouge">COMM_GET_VALUES</code>, 25 fields) is byte-identical between VESC firmware 6.00 and 7.x, and the only difference on the wire is the major version number.</p>

<p>This post walks through the mechanisms: why VESC Tool’s command line cannot connect over the network and why a script loaded into it can; a 178-line script in LispBM, the small Lisp interpreter that runs on the controller, that rewrites those two bytes and recomputes the checksum; a serial-port call that silently switches the throttle input off and saves that to permanent storage; the display’s own interactive prompt and the settings file that had it under-reading speed by 18%; a screen where a draw call costs 2.9 ms whether it covers 4 pixels or 9,600; and the test that caught every curve about to reach the screen with its colours reversed.</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="change-a-number-ride-forget-what-it-was">Change a number, ride, forget what it was</h2>

<p>Everyone who runs a VESC ends up in the same loop. Change a current limit. Ride. Change the throttle curve. Ride. Was that better, or was it a headwind? What was it set to three weeks ago?</p>

<p>VESC Tool is a graphical program. You click through tabs, 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 instead of riding, so nobody does it every time. And on a sealed skateboard enclosure the USB port is behind screws, so all of this happens on a phone in a driveway.</p>

<p>The 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>Settings are text files. Run <code class="language-plaintext highlighter-rouge">git diff</code> on a tuning session, review it before it goes near the motors, revert it in one command. <code class="language-plaintext highlighter-rouge">apply</code> reads the settings back after writing them, because the write call with its verify flag off (<code class="language-plaintext highlighter-rouge">setMcconf(false)</code>) accepts a write and discards it; only the flagged version asks the controller to check and confirm. That one took an afternoon.</p>

<h2 id="the-command-line-only-talks-over-a-cable-the-scripting-runtime-does-not">The command line only talks over a cable; the scripting runtime does not</h2>

<p>VESC Tool ships a command-line mode, and I expected it to solve this. Its port option (<code class="language-plaintext highlighter-rouge">--vescPort</code>) goes straight to the serial-connection code and takes a serial device, full stop. An address and port (<code class="language-plaintext highlighter-rouge">--vescPort 192.168.1.100:65102</code>) is rejected. A fake serial port bridged to the network socket (a <code class="language-plaintext highlighter-rouge">socat</code> pseudo-terminal) opens, and then the handshake never completes: VESC Tool drives the port directly and the fake does not behave like a real one. I wrote that down as impossible.</p>

<p>It isn’t. VESC Tool also accepts <code class="language-plaintext highlighter-rouge">--loadQml</code>, which loads a script written in QML, Qt’s user-interface language, and a script loaded that way runs <em>inside the application</em>, with <code class="language-plaintext highlighter-rouge">VescIf</code> in scope. <code class="language-plaintext highlighter-rouge">VescIf</code> is the one object that holds the whole connection and configuration interface, and its <code class="language-plaintext highlighter-rouge">connectTcp()</code> method can be called from the script. The phone app has a <strong>Wireless Bridge to Computer (TCP)</strong> mode on its Start page, listening on port 65102, one client at a time. The laptop talks to the phone over Wi-Fi and the phone talks to the controller over Bluetooth, the link it always uses.</p>

<p><img src="/images/blog/vesc-bridge-path.svg" alt="The laptop, running VESC Tool with a script loaded, sends commands over Wi-Fi to the VESC Tool app on the phone, which forwards them over Bluetooth to the motor controller sealed inside the deck; the controller's USB port is behind screws and is never used." /></p>

<p>The entire minimum viable version, from <a href="https://github.com/isaacrowntree/vesc-workbench/blob/master/docs/connecting.md">docs/connecting.md</a>:</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><code class="language-plaintext highlighter-rouge">--offscreen</code> keeps the window from opening and <code class="language-plaintext highlighter-rouge">console.log</code> goes to standard output. From there the motor configuration, the app configuration and the command interface (<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 the repo; take the file.</p>

<p>The <code class="language-plaintext highlighter-rouge">"x.x"</code> check is load-bearing. The connected flag goes true as soon as the socket is up, several seconds before the controller has sent its parameters, and a configuration read in that window returns defaults that look exactly like a controller that has wiped itself. Every target in <code class="language-plaintext highlighter-rouge">vesc/qml/</code> waits for a real firmware string for that reason.</p>

<h2 id="diagnostics-that-tell-two-failures-apart">Diagnostics that tell two failures apart</h2>

<p>Half of tuning is working out what is wrong, and a still bar in the GUI does not say why it is still.</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>PPM is the pulse signal the hand remote sends, and <code class="language-plaintext highlighter-rouge">ppm-watch</code> distinguishes <em>the remote is not transmitting</em> from <em>the controller is not decoding it</em>, which look identical in VESC Tool and have different fixes. <code class="language-plaintext highlighter-rouge">make check</code> is a port probe against the bridge plus a check for a running desktop VESC Tool, which would be occupying the bridge’s single client slot; it answers in a second instead of a two-minute timeout.</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">motors-off</code> tells the controller to stop driving the motors (<code class="language-plaintext highlighter-rouge">app-disable-output</code>), which is not a configuration change, so nothing needs undoing and nothing is left in a strange state if you walk away.</p>

<h2 id="the-controller-reports-accurately-and-points-at-the-wrong-thing">The controller reports accurately and points at the wrong thing</h2>

<p><a href="https://github.com/isaacrowntree/vesc-workbench/blob/master/docs/findings.md">docs/findings.md</a> is the document I wanted and could not find. Every entry has the same shape: the firmware does something reasonable, reports it correctly, and the report sends you somewhere else.</p>

<table>
  <thead>
    <tr>
      <th>What you see</th>
      <th>What is happening</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Throttle dead after a script ran; a reboot does not fix it</td>
      <td>Starting the serial port from a script (<code class="language-plaintext highlighter-rouge">uart-start</code>) permanently saves “no throttle input” to the controller’s settings</td>
    </tr>
    <tr>
      <td>Remote-control settings go in and come back wrong, so you suspect the remote</td>
      <td>The remote-control settings refuse to be written while the control type is set to “off”</td>
    </tr>
    <tr>
      <td>“did you forget to upload the code”</td>
      <td>The obvious upload call (<code class="language-plaintext highlighter-rouge">lispWriteCode()</code>) does not actually store a script; the code-loader path (<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 controller finished booting</td>
    </tr>
  </tbody>
</table>

<p>The first one is the one to know before running any script. From <code class="language-plaintext highlighter-rouge">bldc/lispBM/lispif_vesc_extensions.c</code>, <code class="language-plaintext highlighter-rouge">ext_uart_start</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">appconf</span><span class="o">-&gt;</span><span class="n">app_to_use</span> <span class="o">==</span> <span class="n">APP_UART</span> <span class="o">||</span>
        <span class="n">appconf</span><span class="o">-&gt;</span><span class="n">app_to_use</span> <span class="o">==</span> <span class="n">APP_PPM_UART</span> <span class="o">||</span>
        <span class="n">appconf</span><span class="o">-&gt;</span><span class="n">app_to_use</span> <span class="o">==</span> <span class="n">APP_ADC_UART</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">appconf</span><span class="o">-&gt;</span><span class="n">app_to_use</span> <span class="o">=</span> <span class="n">APP_NONE</span><span class="p">;</span>
    <span class="n">conf_general_store_app_configuration</span><span class="p">(</span><span class="n">appconf</span><span class="p">);</span>   <span class="c1">// written to FLASH</span>
    <span class="n">app_set_configuration</span><span class="p">(</span><span class="n">appconf</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The setting <code class="language-plaintext highlighter-rouge">app_to_use</code> says which input drives the motor. If it is any of the three modes that include the serial port, “serial only” (3), “remote and serial” (4) or “analogue and serial” (5), then <code class="language-plaintext highlighter-rouge">uart-start</code> writes “none” to permanent storage and the throttle is gone. It also explains config writes that “do not persist”: they persisted fine, and the next <code class="language-plaintext highlighter-rouge">uart-start</code> overwrote them. <code class="language-plaintext highlighter-rouge">make upload-lisp</code> now searches the script for <code class="language-plaintext highlighter-rouge">uart-start</code>, reads the setting, and changes “remote and serial” to “remote only” (4 to 1) and “analogue and serial” to “analogue only” (5 to 2) before uploading, since the firmware is going to clear the serial half either way. Verified on the board: set to “remote only”, the decoder reads a live pulse of between 1.4990 and 1.5020 ms; set to “remote and serial”, it reads nothing at all.</p>

<p>There is a correction in there too. The esk8 forums say VESC traction control interferes with braking. I enabled it and then read <code class="language-plaintext highlighter-rouge">applications/app_ppm.c</code>: traction control lives entirely in the non-brake branch, <code class="language-plaintext highlighter-rouge">mc_interface_set_brake_current</code> is called before that branch is reached, and it disengages on any fault. What it does do is taper drive current smoothly to zero as the two wheels’ speeds drift apart, reaching zero at a configured difference (<code class="language-plaintext highlighter-rouge">tc_max_diff</code>), measured in electrical revolutions per minute, the motor speed as the controller counts it. On this board, with seven magnet pole pairs, a 4.2-to-1 gear ratio and 200 mm wheels, the default of 3,000 is about 3.8 km/h of difference between the wheels, tight enough that normal grass slip keeps triggering the taper, which is the “power surging” people describe and probably where the folklore comes from. Doubling it to 6,000 (about 7.7 km/h) is what runs here, confirmed in motion.</p>

<h2 id="two-bytes-stood-between-the-display-and-firmware-7">Two bytes stood between the display and firmware 7</h2>

<p>All of this exists because of a smaller problem. I have a LaCroix Nazaré electric skateboard with a FOCBOX Unity sealed in the enclosure, ridden on grass at a golf course, and I wanted it on firmware 7 for its newer motor-control code and for LispBM scripting. The DAVEGA X display refused:</p>

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

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

<p>DAVEGA closed in May 2024. Everyone downgrades. I checked whether that was true first: the vendor’s update index at <code class="language-plaintext highlighter-rouge">davega.eu/fw/index_v5.json</code> still lists a <strong>v5.07rc3, dated 2025-03-11</strong>, newer than the v5.06 the changelog stops at, released after the shop closed and never announced. It carries the same gate strings as every version before it. No firmware DAVEGA shipped accepts VESC 7. <a href="https://github.com/isaacrowntree/vesc-workbench/blob/master/docs/firmware-archive.md">docs/firmware-archive.md</a> records all eight images and their SHA-256s, since the endpoints will not answer forever.</p>

<p>What did not change is the protocol. <code class="language-plaintext highlighter-rouge">lisp/tests/protocol-diff.sh</code> fetches the two firmware source files that define the telemetry reply (<code class="language-plaintext highlighter-rouge">comm/commands.c</code> and <code class="language-plaintext highlighter-rouge">datatypes.h</code>) from the upstream <code class="language-plaintext highlighter-rouge">bldc</code> repository at tag <code class="language-plaintext highlighter-rouge">6.00</code> and at <code class="language-plaintext highlighter-rouge">master</code>, extracts the lines that append each field to the reply, and compares them: 25 fields, same types, same order. The display is not failing to parse anything. It reads two version bytes, sees a 7, and declines to have the conversation.</p>

<p>One thing I got wrong on the way. I claimed the firmware-version reply was structurally identical too. It is not: 7.x appends a four-byte hardware checksum (<code class="language-plaintext highlighter-rouge">buffer_append_uint32(send_buffer, main_calc_hw_crc(), &amp;ind)</code>) that 6.00 lacks. It does not break anything, because the display reads the major and minor version at fixed positions, bytes 1 and 2, and the length byte accounts for the extra word, but my first version of the comparison script only matched three of the four ways the firmware writes into a reply and missed it. The script now compares the appended fields in that reply too, and the test fails the build only on the packet that matters.</p>

<p>VESC firmware 6.06 lets a LispBM script hand a packet to the firmware’s own command decoder (<code class="language-plaintext highlighter-rouge">cmds-proc</code>) and receive the reply as an event (<code class="language-plaintext highlighter-rouge">event-cmds-data-tx</code>). So a script on the controller can take over the serial line the display is wired to, hand every packet to the real handler, and rewrite one reply on the way out. From <code class="language-plaintext highlighter-rouge">lisp/src/proxy.lisp</code>:</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="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">d</span>
<span class="nv">}</span><span class="p">)</span>
</code></pre></div></div>

<p>The rewrite is 27 lines; the whole shim, serial reader and debug counters included, is 178. The checksum function takes no start offset, which is why the payload is copied into its own buffer first. Everything else passes through untouched.</p>

<p>Three things made it harder than the listing suggests. Thirteen commands (numbers 62, 66 to 72, 80, 83, 90, 116, 125 and 158) are handled on a separate blocking thread and reply through a different path, never through the event the shim listens to; forward one and no reply ever comes, and the firmware is left marked as busy. So the shim answers those itself, and answers the ping over the CAN bus (the wire between the Unity’s two halves) with a precomputed reply. The first read loop asked for both header bytes in one call and dropped any payload that arrived short, which cost whole round trips: reading the header and then the payload separately took the reply rate from about 6% to about 70%. And the safe reader was not free until it was made so. Measured on the Unity over three windows of 40 to 45 s each:</p>

<table>
  <thead>
    <tr>
      <th>Reader</th>
      <th>Frames read per second</th>
      <th>Telemetry replies per second</th>
      <th>Interpreter CPU</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Original (one 2-byte header read, short reads ignored)</td>
      <td>12.3/s</td>
      <td>5.1/s</td>
      <td>5.5%</td>
    </tr>
    <tr>
      <td>Byte-at-a-time start hunt</td>
      <td>11.3/s</td>
      <td>4.5/s</td>
      <td>7.9%</td>
    </tr>
    <tr>
      <td>2-byte fast path, explicit recovery</td>
      <td>11.8/s</td>
      <td>4.8/s</td>
      <td>7.3%</td>
    </tr>
    <tr>
      <td><strong>Current</strong> (as above, plus one read for the frame body)</td>
      <td><strong>12.0/s</strong></td>
      <td><strong>5.0/s</strong></td>
      <td><strong>5.6%</strong></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">lisp/tests/bench_reader.lisp</code> counts serial-port calls, which is what the interpreter pays for. The shipped reader was spending three reads per frame when the length byte already said how much was coming; fetching the body in one call took it to two, and the CPU cost of the framing guarantees came back to noise.</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="test-the-lisp-in-the-interpreter-that-runs-it">Test the Lisp in the interpreter that runs it</h2>

<p>If you do anything custom on a VESC, LispBM is where it happens:</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><code class="language-plaintext highlighter-rouge">test-lisp</code> runs the scripts in the upstream LispBM interpreter’s own command prompt, in Docker, with stand-ins for the VESC-specific functions, rather than in a Python re-implementation. LispBM names are case-insensitive, so a function <code class="language-plaintext highlighter-rouge">W</code> collides with an alias <code class="language-plaintext highlighter-rouge">w</code> and silently calls itself. <code class="language-plaintext highlighter-rouge">t</code> is a special symbol that never resolves from the environment, so declaring a variable named <code class="language-plaintext highlighter-rouge">t</code> kills the script that uses it. The serial read function takes its timeout as the fifth argument, and the serial write function takes no length and writes the whole array. The real interpreter catches all of these; a reimplementation catches none.</p>

<p>Upload happens in 384-byte chunks with a one-second timeout per chunk, which is why <code class="language-plaintext highlighter-rouge">lisp/minify.py</code> exists and why the shim is written as tightly as it is.</p>

<p>The shim is retired now. The script slot on the controller runs a flight recorder instead: the session’s peak values, the first fault along with the voltage, motor speed, duty cycle and current that explain it, and traction-control engagements, which nothing on the controller reports today. It runs for hours on a board someone rides, so “probably fine” was not an answer. The whole ride plays through the real interpreter at three lengths and the heap is compared: identical after 500 ticks and after 25,000, about eighty minutes, with every high-water branch taken. The soak also found <code class="language-plaintext highlighter-rouge">uptime</code> accumulating 0.2 per tick into a float and reading 5000.18 s after exactly 5000; it is derived from the sample count now.</p>

<h2 id="the-display-is-a-small-computer-you-can-log-into">The display is a small computer you can log into</h2>

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

<p>Its firmware is closed, but the X is an ESP32, the same Wi-Fi microcontroller found in a lot of hobby electronics, running MicroPython, a small Python for microcontrollers, with a USB-to-serial chip behind the micro USB port. Hold UP and DOWN at boot and the startup script enters WebREPL, an interactive Python prompt over Wi-Fi, before any user code runs. From there the filesystem is in front of you. Settings are a plain <code class="language-plaintext highlighter-rouge">/config.json</code>, which is how I found the display configured for 175 mm wheels on a 72-tooth to 16-tooth gear pair when the board runs 200 mm wheels on 84 to 20. It had been under-reading speed by 18% for as long as I had owned it. <code class="language-plaintext highlighter-rouge">make davega-settings</code> fixed it.</p>

<p>It also means the screen is programmable. The startup script names its boot steps in order, and one of them runs a user-supplied <code class="language-plaintext highlighter-rouge">/start.py</code> <em>before</em> the stock app starts. A replacement dashboard is one file you can delete, and holding UP at boot stands it down. The version check lives in the stock app, not in the class that talks to the controller. A dashboard I wrote reads firmware 7 directly, and the shim stops being necessary at all.</p>

<h2 id="ten-dashboards-for-a-panel-that-cannot-draw-a-curve">Ten dashboards for a panel that cannot draw a curve</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 its own layout rather than its own palette: a full analogue tachometer, hexagonal shards on a diagonal split, three hairline arcs, one enormous 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 one idea from each. Every picture in this post is the real screen code run through a host stand-in for the panel, rendering the pixels it produced.</p>

<p>The pictures are not the interesting part. The panel cannot draw a curve. The driver for its controller chip (an ILI9341) offers three operations, fill a rectangle, set a pixel and write a block of pixels, and no line, circle or polygon. I expected cost to scale with pixels. Measured on the device:</p>

<table>
  <thead>
    <tr>
      <th>Primitive</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> per call, independent of size</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">pixel</code></td>
      <td><strong>2.29 ms</strong> per call</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">print</code> (8×8 char)</td>
      <td><strong>8.6 ms</strong> per character</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 (40 kB) 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, costs <strong>481 ms for an arc 96 pixels in radius</strong>, four times the budget for a whole frame, to draw one gauge.</p>

<p>The way through is the third operation. <code class="language-plaintext highlighter-rouge">davega/gui/bands.py</code> draws the curve into an in-memory pixel buffer and pushes it to the panel in one block write. There is not enough memory for a buffer the size of the screen, so the screen is painted in horizontal bands with one small buffer reused all the way down.</p>

<p><img src="/images/blog/davega-band-rendering.svg" alt="Two panels. Left: a pixel buffer for the whole 240 by 320 screen does not fit in the 98 kilobytes free; even a 200 by 100 buffer fails to allocate. Right: one 240 by 20 band is reused down the screen; the whole curve is drawn in screen coordinates each time, only the pixels that fall inside the band are kept, and each band is pushed to the panel in one call of about six milliseconds." /></p>

<p>The dial face, bezel and tick marks are composed once, as furniture; the moving sweep is composed the same way into a band that covers only the dial.</p>

<p>I tried 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 rounds its own start angle to a pixel, and a partial repaint that disagrees with a full repaint is worse than one that costs a few more milliseconds. Arcs repaint whole; everything straight still repaints only its tip. All 810 transitions between test frames and themes now agree exactly.</p>

<p>Measured on the panel with five screens live, before the curved layouts landed, against telemetry arriving five times a second:</p>

<table>
  <thead>
    <tr>
      <th>What is painted</th>
      <th>Time</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Everything, from blank</td>
      <td>472 to 763 ms</td>
    </tr>
    <tr>
      <td>One settled frame, digits through the character path</td>
      <td>82 ms</td>
    </tr>
    <tr>
      <td>One settled frame, digits through the panel’s own 3-by-5 number routine</td>
      <td>23 to 29 ms</td>
    </tr>
  </tbody>
</table>

<p>The panel’s own number-drawing routine is native and about three times cheaper than drawing digits as text characters; that change alone took a settled frame from 82 ms to 25 ms.</p>

<h2 id="the-harness-lied-first">The harness lied first</h2>

<p>A 2.8-inch screen bolted to a deck is a bad place to iterate a design. So before the first dashboard there was <code class="language-plaintext highlighter-rouge">davega/harness/display.py</code>: a stand-in for the panel that records every draw call, writes the result out as a PNG using nothing but Python’s standard library, and models the panel’s measured cost per call. A screen is a function of three inputs, the display, the current telemetry frame and the board’s configuration, with no hidden state, so the same code runs on the device and in continuous integration, and <code class="language-plaintext highlighter-rouge">make gui-test</code> runs <strong>642 checks</strong> with no hardware.</p>

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

<ul>
  <li><strong>Anything drawn outside the 240-by-320 screen</strong> fails the build rather than being clipped where you might not notice.</li>
  <li><strong>Reference images</strong> for every frame in the test set: standstill, full throttle, hard regenerative braking, a thermal power cut, battery empty, battery full, a fault at speed. The frames are generated from the board’s verified configuration (a battery of twelve cells in series and four in parallel, 17 Ah; 80 A per motor; 30 A out of and 8 A back into the battery per side; 4.2-to-1 gearing; 200 mm wheels), so extremes a ride rarely produces are covered on purpose.</li>
  <li><strong>Partial redraw against a full repaint</strong> across all 810 transitions. Partial redraw’s failure mode is stale pixels, and the two paths must agree byte for byte. This caught the fault banner staying on the screen after the controller recovered.</li>
  <li><strong>Fixed labels against live regions.</strong> A label is drawn once; a region repaints when its value changes; the panel’s font paints its own background. A label inside a region’s box is erased the first time that value moves and never comes back.</li>
</ul>

<p>That last test exists because the harness was flattering the design. Its <code class="language-plaintext highlighter-rouge">print</code> 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 at once. A parity test also asserts every colour declared in <code class="language-plaintext highlighter-rouge">themes.py</code> appears in the rendered mockups; it immediately found five fault colours that existed only in code.</p>

<h2 id="running-the-same-code-in-the-displays-own-python">Running the same code in the display’s own Python</h2>

<p>All of that runs under desktop Python, which does not tell you whether the code will <em>start</em> on the board. <code class="language-plaintext highlighter-rouge">make test-device</code> builds <strong>MicroPython 1.14</strong>, the version the DAVEGA reports, with the pixel-buffer module compiled in, and drives the real <code class="language-plaintext highlighter-rouge">gui</code> package through a whole ride: boot, telemetry replayed from two frames recorded off the Unity, every screen, the buttons through their real 50 ms press-settling delay, the menu, a controller that stops answering, and the recovery.</p>

<p>It earned its keep on the first run. A colour on this panel is two bytes. MicroPython writes those two bytes in the processor’s native order, low byte first, and the panel expects the high byte first, with the block write streaming the buffer to it unchanged. <strong>Every curve would have reached the panel with its colours reversed.</strong> Red drawing blue. It could not show up off the device because the pure-Python stand-in writes the high byte first directly: the harness and the hardware disagreed and only the hardware was right. The two bytes are now swapped on the way into the native drawing path, the byte order is probed rather than assumed, and the two paths render side by side and compare byte for byte.</p>

<p>The other thing it watches is memory use, because 98 kB of free memory and a two-hour ride is a bad combination. It measures two consecutive windows of 400 frames each, since one window cannot tell a leak from a warm-up; caches fill and animations settle in the first. Absolute byte counts are reported rather than asserted, because this is MicroPython built for a 64-bit laptop, where every reference is twice the width it is on the board. Only the drift between the two windows counts.</p>

<p>Which is exactly the limit that bit me twice on the board. Switching theme killed the dashboard and handed the screen to the stock app’s “initializing”; the error file on the display named the allocation, 19,200 bytes in the band painter, claimed fresh on every call at the moment memory was at its worst. Then, with that fixed, a cold boot failed the same way asking for 9,600 bytes at the first curve, because the menu now remembers the rider’s choice and it had been left on a layout with a semicircular gauge. The buffer is now claimed at boot, before any screen exists, and the reserve routine takes the largest band it can get, falling back through 20, 12, 8 and 4 rows. A layout too heavy for what is left drops back to the default arrangement and writes that choice down, so the next boot does not fail the same way. Verified on the board from a clean start: every layout boots with about 31 kB to spare. A dead screen on a deck is worse than the wrong colours.</p>

<h2 id="what-the-board-does-now">What the board does now</h2>

<p>Telemetry is live. A DAVEGA X running a dashboard I wrote reads a FOCBOX Unity on firmware 7.00 directly, no version spoofing in the path. Two things the reference firmware could not have told me: the serial port transmits on pin 17 and receives on pin 16, the reverse of what the reference code’s attribute names suggest, and a firmware-7 reply is 79 bytes, not the 70 the reference buffer allows. And MicroPython’s serial read blocks for the port’s whole timeout when nothing has arrived, so asking whether anything is waiting before reading made a real difference:</p>

<table>
  <thead>
    <tr>
      <th>Serial polling</th>
      <th>Per read</th>
      <th>Full pass over both controllers</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Read straight away</td>
      <td>211 ms</td>
      <td>320 ms</td>
    </tr>
    <tr>
      <td>Ask whether data is waiting first</td>
      <td>17 ms</td>
      <td>123 ms</td>
    </tr>
  </tbody>
</table>

<p>A Unity is two controllers in one case and the standard reply carries only the one that answered, so <code class="language-plaintext highlighter-rouge">davega/gui/vesc.py</code> asks the near side over the wire and the far side through it over the CAN bus, then combines them the way DAVEGA’s own Unity code does: battery current and energy summed, per-motor figures averaged, distance counted once. Temperature is the one divergence; it takes the hotter, because an average hides the controller about to cut its power.</p>

<p>Charge and range use an internal-resistance model rather than intuition. The pack’s resistance is measured as the slope of voltage against current once 8 A of spread has been seen, and the method recovers 0.0400 ohms on a simulated 40-milliohm pack. A twelve-cell pack resting at 48 V and pulling 28 A through 40 milliohms reads 46.9 V at the terminals; a gauge that reads the terminal voltage calls that 41% and the truth is 51%. That ten-point swing is the whole “battery drops when I accelerate” complaint. Range is worked out by draining the modelled pack step by step rather than dividing remaining energy by today’s consumption rate: at a measured 18.7 Wh per km from 45.8 V that is 15.2 km rather than 18.0, shorter, which is the uncomfortable direction and the right one.</p>

<p>The dashboard ships pre-compiled. MicroPython compiles a <code class="language-plaintext highlighter-rouge">.py</code> file every time it imports it, and on this ESP32 that compile was most of the wait between switching on and seeing a number: 154 kB of source against 62 kB of compiled <code class="language-plaintext highlighter-rouge">.mpy</code> files. <code class="language-plaintext highlighter-rouge">start.py</code> stays plain source, because an escape hatch that depends on the build tools having run is not one.</p>

<p>The board is tuned for grass at 80 A a side, traction control on, and it has been ridden. Speed, current, charge and range on the screen, both controllers read, at 40 km/h on grass. Hardware coverage is one FOCBOX Unity, one board, one display; the connection layer and the config workflow are not Unity-specific, but nobody has proved that on other hardware yet. <code class="language-plaintext highlighter-rouge">vesc/profiles/</code> holds connection details only, deliberately not current limits or gearing. Copying a stranger’s motor tuning is how packs and motors get damaged.</p>

<p>Three things I would tell myself at the start. Read the dependency before probing it: the VESC serial protocol is written down in the reference DAVEGA code (janpom/davega), and an evening guessing at its function signatures was worth ten minutes of reading. Test in the interpreter that will run the code, because the two bugs that would have reached the deck, reversed colours and a 9,600-byte out-of-memory error, were invisible under desktop Python by construction. And when a device you have not touched disagrees with a value you typed in, the device is the better witness; the DAVEGA’s battery-capacity setting said 17,000 mAh for months before I stopped believing the pack had six cells in parallel.</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[vesc-workbench scripts VESC Tool, the desktop program for configuring a VESC motor controller, over the phone app's Bluetooth bridge, so a controller sealed inside a skateboard can be read, written and verified with no USB cable. It started because a FOCBOX Unity on firmware 7.00 stopped talking to its DAVEGA X display over two version bytes, and ended with ten dashboards for that display, tested in the interpreter that runs them.]]></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 an uptime monitor that requests your endpoints on a schedule, and it runs entirely inside a single Cloudflare Worker (a small program on Cloudflare’s servers): a timer runs the checks, a state machine with no hidden inputs decides when something is genuinely broken, and the result goes to Slack, a signed webhook, a direct call to another Worker, or an AI agent. The version it replaces alerted exactly once on a multi-day outage and then went silent while still down. This one is 3,840 lines of production TypeScript with 3,801 lines of tests beside it — 271 unit tests and 40 more against a real database in workerd, the open-source runtime that Workers run on. <strong>MIT licensed.</strong></p>

<p>This post walks through what the v3 rewrite changed and why: the “still unhealthy” transition the old state machine could not express, why three storage systems became one (the rewrite commit deleted 2,386 lines), how a secret can be referred to by name in a database anyone with the dashboard can edit without its value ever landing in a row, why a failing response body is scrubbed of secrets <em>before</em> it is cut short, the test that keeps the API document an agent reads in step with the routes that exist, and the thing the docs claim about signed action links that the code does not yet do.</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="where-the-model-is-allowed-and-where-it-is-not">Where the model is allowed, and where it is not</h2>

<p>There is an obvious version of an AI-era monitoring tool where a model looks at the response and decides whether you have a problem. clawdwatch 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>Everything below is either “make the detection boring and trustworthy” or “make the handoff useful.”</p>

<h2 id="the-state-machine-could-not-say-still-down">The state machine could not say “still down”</h2>

<p>The transition function is 118 lines in <code class="language-plaintext highlighter-rouge">src/engine/transition.ts</code>, and its signature is the design:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">computeTransition</span><span class="p">(</span>
  <span class="nx">state</span><span class="p">:</span> <span class="nx">CheckState</span><span class="p">,</span>
  <span class="nx">result</span><span class="p">:</span> <span class="nx">CheckResult</span><span class="p">,</span>
  <span class="nx">check</span><span class="p">:</span> <span class="nx">CheckConfig</span><span class="p">,</span>
  <span class="nx">now</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span>
  <span class="nx">nextIncidentId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="p">):</span> <span class="p">{</span> <span class="nl">state</span><span class="p">:</span> <span class="nx">CheckState</span><span class="p">;</span> <span class="nl">transition</span><span class="p">:</span> <span class="nx">Transition</span> <span class="p">}</span> <span class="p">{</span>
</code></pre></div></div>

<p>The current time is passed in as a parameter and never read from the system clock; the incident id is supplied by the caller. That is what lets the test suite walk a check through a week of results in 273 lines without a clock. Four states, <code class="language-plaintext highlighter-rouge">unknown</code>, <code class="language-plaintext highlighter-rouge">healthy</code>, <code class="language-plaintext highlighter-rouge">degraded</code> and <code class="language-plaintext highlighter-rouge">unhealthy</code>, and the moves between them:</p>

<p><img src="/images/blog/clawdwatch-state-machine.svg" alt="The four check states as circles: unknown, healthy, degraded and unhealthy. First success takes unknown to healthy silently; a failure takes healthy to degraded silently; three failures in a row take degraded to unhealthy and open an alert; a success takes degraded back to healthy silently, or unhealthy back to healthy with a recovered alert. The loop from unhealthy back to itself, which sends a reminder every hour while the check is still failing, is highlighted because the previous version had no such loop and went quiet after the first alert." /></p>

<p>The threshold defaults to three consecutive failures. The move that matters is the loop from unhealthy back to unhealthy. The file header records the bug it fixes: in v2 that case returned nothing forever, so a multi-day outage alerted once on day one and then went quiet while still down. Reminders can only be sent if the state machine has a name for “still broken, and it has been a while”:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="c1">// Already unhealthy: remind only when the cadence has elapsed.</span>
  <span class="kd">const</span> <span class="nx">interval</span> <span class="o">=</span> <span class="nx">check</span><span class="p">.</span><span class="nx">reminderIntervalMs</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">lastAlert</span> <span class="o">=</span> <span class="nx">state</span><span class="p">.</span><span class="nx">lastAlertAt</span> <span class="p">?</span> <span class="nb">Date</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">lastAlertAt</span><span class="p">)</span> <span class="p">:</span> <span class="kc">null</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">downSince</span> <span class="o">=</span> <span class="nx">state</span><span class="p">.</span><span class="nx">downSince</span> <span class="p">?</span> <span class="nb">Date</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">downSince</span><span class="p">)</span> <span class="p">:</span> <span class="nx">now</span><span class="p">;</span>

  <span class="k">if </span><span class="p">(</span><span class="nx">interval</span> <span class="o">!==</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="nx">lastAlert</span> <span class="o">!==</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="nx">now</span> <span class="o">-</span> <span class="nx">lastAlert</span> <span class="o">&gt;=</span> <span class="nx">interval</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">next</span><span class="p">.</span><span class="nx">lastAlertAt</span> <span class="o">=</span> <span class="nx">at</span><span class="p">;</span>
    <span class="k">return</span> <span class="p">{</span>
      <span class="na">state</span><span class="p">:</span> <span class="nx">next</span><span class="p">,</span>
      <span class="na">transition</span><span class="p">:</span> <span class="p">{</span> <span class="na">kind</span><span class="p">:</span> <span class="dl">'</span><span class="s1">reminder</span><span class="dl">'</span><span class="p">,</span> <span class="na">downSinceMs</span><span class="p">:</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">max</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nx">now</span> <span class="o">-</span> <span class="nx">downSince</span><span class="p">)</span> <span class="p">},</span>
    <span class="p">};</span>
  <span class="p">}</span>
</code></pre></div></div>

<p>The default cadence is one hour; <code class="language-plaintext highlighter-rouge">null</code> turns reminders off. The test drives a sustained failure through the function and asserts the sequence <code class="language-plaintext highlighter-rouge">['opened', 'reminder', 'none', 'reminder']</code>.</p>

<p>Two more v2 defects were structural, and the orchestrator’s header names them: checks ran one after another, so ten checks with a 10 s timeout and retries could use up the time the scheduled run was allowed, and alerts fired one per check inside that loop, so a ten-endpoint outage was ten notifications. Now checks run six at a time, transitions are collected per run into opened, recovered and reminder events plus a single summary, and a quiet run emits nothing at all.</p>

<h2 id="three-storage-systems-became-one">Three storage systems became one</h2>

<p>D1, Cloudflare’s hosted SQLite. Six tables — <code class="language-plaintext highlighter-rouge">checks</code>, <code class="language-plaintext highlighter-rouge">check_state</code>, <code class="language-plaintext highlighter-rouge">check_results</code>, <code class="language-plaintext highlighter-rouge">incidents</code>, <code class="language-plaintext highlighter-rouge">maintenance_windows</code>, <code class="language-plaintext highlighter-rouge">notifier_deliveries</code> — in one migration whose header says what it replaced:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- One storage system: D1. (v2 split hot state into an R2 JSON blob and</span>
<span class="c1">-- history into Analytics Engine that nothing ever read.)</span>
</code></pre></div></div>

<p>That is not a figure of speech. The v2 state module opens with “R2 state persistence for monitoring … History is stored in Analytics Engine” (R2 is Cloudflare’s file storage and Analytics Engine its time-series store), and the orchestrator wrote a data point to Analytics Engine on every result. Nothing in v2 ever read that data back; the rewrite commit lists it as “Analytics Engine (write-only)”. Two storage systems, two consistency stories, a whole class of “which one is right?” bugs, for a dataset with no reader.</p>

<p>The rewrite commit touched 23 files: 3,058 insertions, 2,386 deletions. <code class="language-plaintext highlighter-rouge">db.ts</code> (370 lines) and <code class="language-plaintext highlighter-rouge">state.ts</code> (69) went; <code class="language-plaintext highlighter-rouge">transition.ts</code> (118), <code class="language-plaintext highlighter-rouge">secrets.ts</code> (195) and <code class="language-plaintext highlighter-rouge">store/d1.ts</code> (398) arrived. The integration suite applies the shipped migration and runs in workerd against a local stand-in for D1, so the SQL is exercised rather than mocked — 40 tests in 1.94 s.</p>

<p>One schema decision worth copying: <code class="language-plaintext highlighter-rouge">check_state</code> deliberately has no foreign key to <code class="language-plaintext highlighter-rouge">checks(id)</code>. A run writes every check’s result and state in one batch, and if a check were deleted mid-run a foreign-key violation would fail the batch and silently lose that tick for every other check too.</p>

<p>Results are kept 48 hours (<code class="language-plaintext highlighter-rouge">historyRetentionHours: 48</code>) and pruned every run. The pruning of <code class="language-plaintext highlighter-rouge">notifier_deliveries</code> keeps <code class="language-plaintext highlighter-rouge">MAX(id)</code> per notifier, because alerts are sparse and deleting a notifier’s only row would make it vanish from the dashboard.</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 a check stores a reference:</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>A reference is a name in <code class="language-plaintext highlighter-rouge">${...}</code> braces, and it is turned into the real value at exactly one point, where the runner builds the request headers, throwing an error if a name is missing rather than sending an empty header. Every path <em>leaving</em> the system goes through <code class="language-plaintext highlighter-rouge">scrub()</code> first, which does the substitution in reverse — each literal value is replaced with its <code class="language-plaintext highlighter-rouge">${NAME}</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">scrub</span><span class="p">(</span><span class="nx">text</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">secrets</span><span class="p">:</span> <span class="nx">SecretMap</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">out</span> <span class="o">=</span> <span class="nx">text</span><span class="p">;</span>
  <span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="p">[</span><span class="nx">name</span><span class="p">,</span> <span class="nx">value</span><span class="p">]</span> <span class="k">of</span> <span class="nb">Object</span><span class="p">.</span><span class="nf">entries</span><span class="p">(</span><span class="nx">secrets</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">value</span> <span class="o">||</span> <span class="nx">value</span><span class="p">.</span><span class="nx">length</span> <span class="o">&lt;</span> <span class="nx">MIN_SECRET_LENGTH</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
    <span class="nx">out</span> <span class="o">=</span> <span class="nx">out</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="nx">value</span><span class="p">).</span><span class="nf">join</span><span class="p">(</span><span class="s2">`</span><span class="se">\$</span><span class="s2">{</span><span class="p">${</span><span class="nx">name</span><span class="p">}</span><span class="s2">}`</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="nx">out</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">MIN_SECRET_LENGTH</code> is 8; shorter values are too common to treat as secrets. Alert payloads go further and carry a <code class="language-plaintext highlighter-rouge">CheckSummary</code> with headers and body dropped entirely, on the principle that what is absent cannot leak.</p>

<p>Writing a check that contains a literal secret value is rejected with a 400 whose message names the reference you should have used. The test posts a header of <code class="language-plaintext highlighter-rouge">hc-super-secret-value</code> and asserts the error contains <code class="language-plaintext highlighter-rouge">${HEALTHCHECK_SECRET}</code>. And there is a test named <code class="language-plaintext highlighter-rouge">property: no resolved secret escapes</code> — not a generated property-based test, a hand-written exhaustive loop over five placements (a header, a <code class="language-plaintext highlighter-rouge">Bearer</code> authorisation header, a JSON body, a query string, all three at once) times every secret — asserting first that the write guard throws, and then that if the value had somehow reached storage, the API view, the alert summary and a log line are all clean.</p>

<p>That guard is what makes a dashboard-editable, database-backed monitor safe to open-source at all. For a token that belongs to a whole domain rather than one check — one that lets the monitor past a web application firewall, say — header rules keyed by host pattern carry the same references.</p>

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

<p>By default a response body is read to evaluate assertions and discarded; what is stored is the assertion 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, for five hours, while the alert carried none of it. So a check can opt in with <code class="language-plaintext highlighter-rouge">"captureBodyOnFailure": true</code>, and <code class="language-plaintext highlighter-rouge">buildBodySnippet()</code> is deliberately ordered:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">buildBodySnippet</span><span class="p">(</span><span class="nx">raw</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">secrets</span><span class="p">:</span> <span class="nx">SecretMap</span><span class="p">):</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">collapsed</span> <span class="o">=</span> <span class="nf">scrub</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">secrets</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">\s</span><span class="sr">+/g</span><span class="p">,</span> <span class="dl">'</span><span class="s1"> </span><span class="dl">'</span><span class="p">).</span><span class="nf">trim</span><span class="p">();</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">collapsed</span> <span class="o">===</span> <span class="dl">''</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
  <span class="k">return</span> <span class="nx">collapsed</span><span class="p">.</span><span class="nx">length</span> <span class="o">&lt;=</span> <span class="nx">MAX_BODY_SNIPPET_LENGTH</span>
    <span class="p">?</span> <span class="nx">collapsed</span>
    <span class="p">:</span> <span class="s2">`</span><span class="p">${</span><span class="nx">collapsed</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nx">MAX_BODY_SNIPPET_LENGTH</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)}</span><span class="s2">…`</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Scrub before truncating, so a secret straddling the cut is masked rather than half-printed. The cap is 512 characters — long enough for a JSON error or an HTML title, short enough that it cannot become a bulk exfiltration channel. Capture only runs when assertions failed, only for textual content types (the regex accepts <code class="language-plaintext highlighter-rouge">text/*</code>, JSON, XML, <code class="language-plaintext highlighter-rouge">problem+json</code> and <code class="language-plaintext highlighter-rouge">*+json</code>, and treats an absent content-type as textual because many error paths omit it).</p>

<p>The snippet reaches the webhook notifier and the dashboard, both already trusted with the monitoring database. It never reaches Slack: <code class="language-plaintext highlighter-rouge">bodySnippet</code> does not appear anywhere in <code class="language-plaintext highlighter-rouge">slack.ts</code>, and a test sets one to <code class="language-plaintext highlighter-rouge">connection refused to db-primary</code> and asserts neither string is in the rendered payload. Slack is a wide, retained audience.</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. Every alert carries a <code class="language-plaintext highlighter-rouge">links</code> object — <code class="language-plaintext highlighter-rouge">incident</code>, <code class="language-plaintext highlighter-rouge">ack</code>, <code class="language-plaintext highlighter-rouge">annotate</code>, <code class="language-plaintext highlighter-rouge">runNow</code>, <code class="language-plaintext highlighter-rouge">maintenance</code>, <code class="language-plaintext highlighter-rouge">capabilities</code> — and <code class="language-plaintext highlighter-rouge">GET /api/agent.md</code> describes the API so an agent can act on what it was handed.</p>

<p>Here is where I have to correct the documentation, including my own. The docs say the action links “expire after about an hour”. The signing machinery exists: a function mints a link by signing the action and its expiry time with the same keyed hash the webhook notifier uses, a verifier checks the expiry and compares the signature in constant time, and the authentication layer accepts such a signature in the query string, scoped to one action. It is tested. But the orchestrator’s link builder emits plain URLs, and the minting function has no production call site. As shipped, an agent that receives an alert still needs an identity — a Cloudflare Access service token — to write back. The signed-link path is the next thing to wire in, and until then the docs overstate it.</p>

<p>What is real is <code class="language-plaintext highlighter-rouge">agent.md</code>. It is generated from a 17-entry <code class="language-plaintext highlighter-rouge">ROUTES</code> table in <code class="language-plaintext highlighter-rouge">src/routes/agent-md.ts</code>, and two tests keep it honest in both directions:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="kd">const</span> <span class="nx">mounted</span> <span class="o">=</span> <span class="nf">app</span><span class="p">(</span><span class="nf">fakeDb</span><span class="p">().</span><span class="nx">db</span><span class="p">).</span><span class="nx">routes</span>
      <span class="p">.</span><span class="nf">filter</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">path</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">'</span><span class="s1">/api/</span><span class="dl">'</span><span class="p">))</span>
      <span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s2">`</span><span class="p">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">method</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">path</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>

    <span class="kd">const</span> <span class="nx">documented</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Set</span><span class="p">(</span><span class="nx">ROUTES</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s2">`</span><span class="p">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">method</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">path</span><span class="p">}</span><span class="s2">`</span><span class="p">));</span>
    <span class="kd">const</span> <span class="nx">undocumented</span> <span class="o">=</span> <span class="p">[...</span><span class="k">new</span> <span class="nc">Set</span><span class="p">(</span><span class="nx">mounted</span><span class="p">)].</span><span class="nf">filter</span><span class="p">((</span><span class="nx">m</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">documented</span><span class="p">.</span><span class="nf">has</span><span class="p">(</span><span class="nx">m</span><span class="p">));</span>
    <span class="nf">expect</span><span class="p">(</span><span class="nx">undocumented</span><span class="p">).</span><span class="nf">toEqual</span><span class="p">([]);</span>
</code></pre></div></div>

<p>and the mirror, <code class="language-plaintext highlighter-rouge">documents no route that does not exist</code>. The route replaced an earlier idea: shipping a skill file for agents to install. The file header says why — a static file that duplicates a live API drifts, and in practice never gets installed; the previous generation’s skill claimed 90-day retention for a system that kept 48 hours, and was never copied into the container it was meant for.</p>

<p>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 next to the human comments. There is a worked receiver: <a href="https://github.com/triptechtravel/thinkbot">thinkbot</a>, which takes these alerts and correlates them against GitHub, Datadog and Sentry.</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 receiver 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, no public inbox to defend, and no replay window to reason about. The payload crosses as a structured value rather than a JSON string.</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>The receiver is a <code class="language-plaintext highlighter-rouge">WorkerEntrypoint</code> with an <code class="language-plaintext highlighter-rouge">alert(event)</code> method. <code class="language-plaintext highlighter-rouge">rpc()</code> throws, rather than returning quietly, when the binding is missing — dispatch records delivery outcomes, and an unconfigured binding must show up there as a failure. A silent success is how a dead alert path stays invisible.</p>

<p>One sharp edge, stated in both repos: an RPC call carries <strong>no signature</strong>. Authenticity comes from the binding. Keep signature verification in the HTTP handler and do not let shared downstream code assume it ran. 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; its verifier rejects signatures older than <code class="language-plaintext highlighter-rouge">DEFAULT_TOLERANCE_MS</code>, five minutes.</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>, currently <code class="language-plaintext highlighter-rouge">1</code>. The rule receivers can rely on is in the type’s comment: adding an optional field does not bump it — <code class="language-plaintext highlighter-rouge">bodySnippet</code> was added this way — and removing, renaming or retyping one does. 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.</p>

<h2 id="arriving-through-the-login-proxy-is-not-proof-of-identity">Arriving through the login proxy is not proof of identity</h2>

<p>Mount it behind <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/">Cloudflare Access</a>, Cloudflare’s login proxy. Reads are open by default; writes always require an identity.</p>

<p>There is no API key in the query string. URLs leak into logs, analytics and referrers, and a shared static secret has no identity, no expiry and no revocation. And arriving through Access is not treated as proof in itself, because anyone who learns the Worker’s direct address bypasses the proxy. So <code class="language-plaintext highlighter-rouge">src/auth/jwt.ts</code> fetches the team’s public signing keys from Access (cached for five minutes), refuses any signing algorithm but RS256, verifies the token’s signature against the named key, and checks its expiry, its not-before time, that it was issued for this application and that it was issued by this team. A token minted for another Access application must not work here; that is what keeps a leaked token scoped to this app.</p>

<p>The detail the previous implementation got wrong: a <strong>service token</strong>, the kind of identity a machine caller gets, carries no email address in its token. Its identity is in the <code class="language-plaintext highlighter-rouge">common_name</code> field. Code that only looks for an email silently rejects every machine caller, which is exactly the caller an agent integration needs. Both shapes are handled, and verification failures are returned as a flat 403 without the reason, because the reason tells an attacker which check failed.</p>

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

<h2 id="a-dashboard-that-does-not-interpolate">A dashboard that does not interpolate</h2>

<p>One mark per check run. The tick-strip component’s header explains the choice: a scheduled check produces separate samples, and a smoothed line between them would assert measurements that were never taken. Colour is status, height is response time against the check’s own ceiling, and the marks distinguish “responded badly” (a status code came back) from “unreachable” (no status code at all). Healthy marks are quieted in the stylesheet so amber and red carry the page.</p>

<p>Every delivery is recorded too, so the dashboard can answer the question monitoring tools are worst at: did the last alert actually arrive? That panel had a bug worth owning. Delivery rows are only written when an alert fires, so a failed delivery from initial setup sat red for six days with everything healthy — there was no next alert to overwrite it. The fix ages rows out: after 24 hours a chip renders grey as “last delivery failed”, which says what we actually know. The same commit found that <code class="language-plaintext highlighter-rouge">pruneDeliveries</code> was exported and unit-tested but had no production call site, so the table had grown for the life of the deploy.</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 as a library, 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>Defaults: a check every five minutes, a 10 s timeout, one retry after 5 s, three failures to open an alert, reminders hourly. Assertions cover status code, headers, body, response time and a value picked out of a JSON body — the last of which turns a health endpoint that returns <code class="language-plaintext highlighter-rouge">{"db":"ok","queue":"degraded"}</code> into a signal rather than a 200.</p>

<h2 id="what-this-cost-and-what-it-bought">What this cost and what it bought</h2>

<p>Triptech runs production APIs on Cloudflare Workers, and the monitoring options are a SaaS that bills per check and knows nothing about your account, or a Worker you write yourself and never quite finish. v2 was the second one: three storage systems, a state machine with no word for “still down”, and a skill file describing retention the system did not have.</p>

<p>Three things I would carry to the next project. Inject the clock and the id generator, and the state machine becomes 273 lines of tests that run in milliseconds. Do the secret scrub before the truncation, not after. And generate the document an agent reads from the same table that mounts the routes, with a test in each direction — it is the only kind of documentation that was still true a month later. The capability links are the open item; the docs describe them, the code signs them, and the orchestrator does not yet attach them.</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 inside one Cloudflare Worker: a pure, clock-injected state machine that can say 'still down', D1 as the only storage, `${REF}` secrets that are scrubbed before they are truncated, an API document generated from the route table, and Access JWTs verified against JWKS rather than trusted on arrival. 3,840 lines of TypeScript, 311 tests.]]></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. The two bedding sites each carried a single monolithic Jekyll layout, 1,843 and 1,380 lines, loading Bootstrap 5.3.3, jQuery 3.7.1 and a Font Awesome kit from five CDN hosts before rendering a word. They now run 443 lines of hand-written CSS each on Jekyll 4.4.1 and GitHub Pages, and none of the four loads a CSS framework any more.</p>

<p>This post walks through what the rebuild actually consists of: the line counts before and after; the stockist finder that still reads the owner’s Dropbox CSV at page load and the two-pass state matcher that recovered 74 of 407 retailers a strict regex was dropping; the four things that made it safe to ship an unapproved redesign to <code class="language-plaintext highlighter-rouge">/preview/</code> on the client’s live domain; a Liquid filter chain that doubled a URL; and the <code class="language-plaintext highlighter-rouge">&lt;details&gt;</code> menu, <code class="language-plaintext highlighter-rouge">Intl.DateTimeFormat</code> readout and reduced-motion reveal on Mower Corner.</p>

<!-- more -->

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

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

<p>Breeder’s Choice’s old <code class="language-plaintext highlighter-rouge">_layouts/default.html</code> pulled, in order: a Font Awesome kit from <code class="language-plaintext highlighter-rouge">kit.fontawesome.com</code>, Bootstrap 5.3.3 CSS from jsDelivr, AOS 2.3.1 from unpkg, and at line 1233 jQuery 3.7.1 from <code class="language-plaintext highlighter-rouge">code.jquery.com</code>, Bootstrap’s bundle from jsDelivr again, jQuery CSV 1.0.40 from cdnjs, and AOS’s script from unpkg. Superior Shavings had the same Font Awesome kit, the same Bootstrap and jQuery, jQuery CSV from jsDelivr, and Montserrat from Google Fonts — no AOS, which I had misremembered until I read the blob. Five CDN hosts each, a different five. Mower Corner’s externals were in <code class="language-plaintext highlighter-rouge">_includes/styles.html</code> and <code class="language-plaintext highlighter-rouge">_includes/scripts.html</code>: Bootstrap 5.3.2, Font Awesome 6.4.2 from cdnjs, AOS from unpkg, Oswald and Open Sans from Google Fonts, and every photo from Cloudinary.</p>

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

<p>What replaced it, verified with <code class="language-plaintext highlighter-rouge">wc -l</code>:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>CSS</th>
      <th>JS</th>
      <th>Source pages</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Mower Corner</td>
      <td>1,132 lines, <code class="language-plaintext highlighter-rouge">main.css</code></td>
      <td>210 lines, <code class="language-plaintext highlighter-rouge">site.js</code></td>
      <td>5</td>
    </tr>
    <tr>
      <td>Breeder’s Choice</td>
      <td>443 lines</td>
      <td>228 lines (<code class="language-plaintext highlighter-rouge">site.js</code> 52 + <code class="language-plaintext highlighter-rouge">stockists.js</code> 176)</td>
      <td>10 + a 404</td>
    </tr>
    <tr>
      <td>Superior Shavings</td>
      <td>443 lines</td>
      <td>228 lines (same two files, byte-identical)</td>
      <td>10 + a 404</td>
    </tr>
  </tbody>
</table>

<p>AC Service &amp; Parts is a one-page site whose 698 lines of CSS are inline in <code class="language-plaintext highlighter-rouge">_layouts/home.html</code>; it had a full redesign in April and a refinement pass in August, below. Google Fonts and Analytics still load on all four, and Mower Corner still serves its imagery through Cloudinary, because the 2017 shop photography is flat and underexposed and <code class="language-plaintext highlighter-rouge">e_improve:outdoor</code> plus <code class="language-plaintext highlighter-rouge">e_auto_color</code> on the actual pixels beats any CSS filter. I am not going to claim a purity I did not 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 trades in two states, and two are sister brands owned by the same person, which makes <em>differentiating</em> them the actual design problem.</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 is genuine equity in Colac. The grass stayed and was redrawn as a silhouette; the sky gradient and clip-art operators 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. <code class="language-plaintext highlighter-rouge">main.css</code> declares <code class="language-plaintext highlighter-rouge">--ink: #14181a</code> (cast iron), <code class="language-plaintext highlighter-rouge">--orange: #f2600c</code> (safety orange, the equity colour), <code class="language-plaintext highlighter-rouge">--grass: #2b7134</code> (the traced green from the wordmark’s grass strip), and <code class="language-plaintext highlighter-rouge">--radius: 2px; /* pressed metal, not soft plastic */</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: their own pallet photography, and a kraft bale-label tag carrying the spec the way the printed wrap does. <code class="language-plaintext highlighter-rouge">--forest: #0B4423</code> and <code class="language-plaintext highlighter-rouge">--green: #0F6B36</code> from the logo, on cream and kraft, <code class="language-plaintext highlighter-rouge">--radius: 6px; /* softer than the industrial sibling */</code>. The stylesheet header calls it “the daylight sibling of Superior Shavings’ iron-dark industrial register.”</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 the opposite register: <code class="language-plaintext highlighter-rouge">--iron: #1F2224</code>, the logo’s <code class="language-plaintext highlighter-rouge">--red: #BF2025</code>, a numbered mill line, the spec set as a mono bale ticket. Same structure as Breeder’s Choice, same ethos, different plant. Each site links the other from the story section (“In NSW? Meet Superior Shavings”), the footer, and a <code class="language-plaintext highlighter-rouge">Brand</code> node in the JSON-LD.</p>

<p><strong>AC Service &amp; Parts — refine, not replace.</strong> The paddock palette and harvest photography were already working, so the budget went to what a review found: terracotta text on bone was 3.1:1, and moving it to <code class="language-plaintext highlighter-rouge">--terracotta-dark</code> gave 4.7:1 on bone — and then failed on <code class="language-plaintext highlighter-rouge">--paper</code> and <code class="language-plaintext highlighter-rouge">--bone-dim</code>, so the token darkened again from <code class="language-plaintext highlighter-rouge">#A8562F</code> to <code class="language-plaintext highlighter-rouge">#9A4E2A</code> for 4.5:1 on every ground. Search Console had flagged 30 “Either offers, review, or aggregateRating should be specified” errors, because each catalogue <code class="language-plaintext highlighter-rouge">&lt;li&gt;</code> was typed as a schema.org <code class="language-plaintext highlighter-rouge">Product</code> with only a name and image; the Contentful model has no price or SKU, so those could never validate, and the catalogue is now an <code class="language-plaintext highlighter-rouge">OfferCatalog</code> on the <code class="language-plaintext highlighter-rouge">LocalBusiness</code> node. The two Unsplash heroes were self-hosted, the desktop nav lost an <code class="language-plaintext highlighter-rouge">aria-hidden="true"</code> that had been hiding it from screen readers, “no middlemen” was dropped because SpareX is a distributor, and a second, ungated gtag block at the end of <code class="language-plaintext highlighter-rouge">&lt;body&gt;</code> was removed before it could double-count.</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: 407 and 540 of them, counted from the live CSVs today. The owner maintains both lists in his own Dropbox, and has for years.</p>

<p>The obvious engineering move is to pull the CSV at build time: it server-renders, it’s fast, it’s cacheable. I built it the other way on purpose, and the header of <code class="language-plaintext highlighter-rouge">stockists.js</code> says so:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* Stockist finder — fully dynamic by design.
   The CSV lives in the owner's Dropbox and is fetched in the browser at page
   load, so edits go live with NO site deploy. Do not move this to build time.
   No jQuery, no jquery-csv: ~40 lines of native fetch + parse. */</span>
</code></pre></div></div>

<p>A build-time fetch means his edits do not appear until something triggers a deploy, and the moment his workflow depends on my CI, I have taken his list away from him. He edits a spreadsheet; the site updates. The URL reaches the script as <code class="language-plaintext highlighter-rouge">data-csv="{{ site.stockists_csv }}"</code>, the parser is 23 lines and handles quoted fields and embedded commas, and if the fetch fails the page says so and shows the phone number.</p>

<p>The state matching is where the real bug was. The first version was one regex, <code class="language-plaintext highlighter-rouge">\b(NSW|VIC|QLD|SA|WA|TAS|NT|ACT)\b</code>, against the address fields. The review pass counted what it dropped: 74 of Breeder’s Choice’s 407 rows, and Victoria went from 185 stockists to 258 once they were recovered. 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 loses those rows. So it is two passes:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="kd">function</span> <span class="nf">stateOf</span><span class="p">(</span><span class="nx">s</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">var</span> <span class="nx">hay</span> <span class="o">=</span> <span class="p">(</span><span class="nx">s</span><span class="p">[</span><span class="dl">'</span><span class="s1">Address 2</span><span class="dl">'</span><span class="p">]</span> <span class="o">||</span> <span class="dl">''</span><span class="p">)</span> <span class="o">+</span> <span class="dl">'</span><span class="s1"> </span><span class="dl">'</span> <span class="o">+</span> <span class="p">(</span><span class="nx">s</span><span class="p">[</span><span class="dl">'</span><span class="s1">Address 1</span><span class="dl">'</span><span class="p">]</span> <span class="o">||</span> <span class="dl">''</span><span class="p">);</span>
    <span class="kd">var</span> <span class="nx">m</span> <span class="o">=</span> <span class="nx">hay</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/</span><span class="se">\b(</span><span class="sr">NSW|VIC|QLD|SA|WA|TAS|NT|ACT</span><span class="se">)\b</span><span class="sr">/</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">m</span><span class="p">)</span> <span class="k">return</span> <span class="nx">m</span><span class="p">[</span><span class="mi">1</span><span class="p">];</span>
    <span class="nx">m</span> <span class="o">=</span> <span class="nx">hay</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="sr">/</span><span class="se">\b(</span><span class="sr">new south wales|victoria|queensland|south australia|western australia|tasmania|northern territory|australian capital territory|nsw|vic|qld|sa|wa|tas|nt|act</span><span class="se">)\b</span><span class="sr">/i</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">m</span><span class="p">)</span> <span class="k">return</span> <span class="dl">''</span><span class="p">;</span>
    <span class="kd">var</span> <span class="nx">t</span> <span class="o">=</span> <span class="nx">m</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nf">toLowerCase</span><span class="p">();</span>
    <span class="k">return</span> <span class="nx">STATE_NAMES</span><span class="p">[</span><span class="nx">t</span><span class="p">]</span> <span class="o">||</span> <span class="nx">t</span><span class="p">.</span><span class="nf">toUpperCase</span><span class="p">();</span>
  <span class="p">}</span>
</code></pre></div></div>

<p>That is not defensive programming for its own sake; it is the difference between a stockist appearing and a stockist not existing.</p>

<p>The list renders as indexable HTML across nine pages per site — <code class="language-plaintext highlighter-rouge">stockists/index.md</code> plus <code class="language-plaintext highlighter-rouge">act</code>, <code class="language-plaintext highlighter-rouge">nsw</code>, <code class="language-plaintext highlighter-rouge">nt</code>, <code class="language-plaintext highlighter-rouge">qld</code>, <code class="language-plaintext highlighter-rouge">sa</code>, <code class="language-plaintext highlighter-rouge">tas</code>, <code class="language-plaintext highlighter-rouge">vic</code> and <code class="language-plaintext highlighter-rouge">wa</code> — 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 would reuse anywhere.</p>

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

<p>So the new design shipped to his own live domain at <code class="language-plaintext highlighter-rouge">/preview/</code> on 7 August, two weeks before he approved it, while the old site kept serving <code class="language-plaintext highlighter-rouge">/</code> untouched.</p>

<p><img src="/images/blog/client-sites-preview-layout.svg" alt="Left, two weeks before sign-off: the live domain kept serving the old site at its root and its stockists pages, the new home page and nine new stockist pages lived under /preview/, marked not indexed and left out of the sitemap, and a standalone 404 page linked only to home and the phone number. Right, after sign-off: the preview pages moved to the root in one commit per site, both gates were removed, the old layout and its config aliases deleted, and the 404 page unchanged." /></p>

<p>Four things made that safe:</p>

<ol>
  <li><strong>Every preview page carried two release gates in front matter.</strong> <code class="language-plaintext highlighter-rouge">noindex: true</code> and <code class="language-plaintext highlighter-rouge">sitemap: false</code>, on the home page and all nine stockist pages, so Google never saw a half-approved site or a duplicate of the live one. The og:image default was scoped to <code class="language-plaintext highlighter-rouge">path: "preview"</code> for the same reason: the old pages at <code class="language-plaintext highlighter-rouge">/</code> had to stay byte-identical.</li>
  <li><strong>The old layout stayed the default by doing nothing.</strong> There is no <code class="language-plaintext highlighter-rouge">defaults:</code> layout in <code class="language-plaintext highlighter-rouge">_config.yml</code>. The root <code class="language-plaintext highlighter-rouge">index.md</code> kept <code class="language-plaintext highlighter-rouge">layout: default</code>; every new page under <code class="language-plaintext highlighter-rouge">preview/</code> opted into <code class="language-plaintext highlighter-rouge">layout: home</code> or <code class="language-plaintext highlighter-rouge">layout: stockists</code> explicitly.</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>, and jekyll-seo-tag appended <code class="language-plaintext highlighter-rouge">site.tagline</code> to the live homepage <code class="language-plaintext highlighter-rouge">&lt;title&gt;</code>. So the new templates read <code class="language-plaintext highlighter-rouge">stockists_csv</code>, <code class="language-plaintext highlighter-rouge">phone_intl</code> and <code class="language-plaintext highlighter-rouge">brand_tagline</code>, and both sets lived in <code class="language-plaintext highlighter-rouge">_config.yml</code>, each commented with which layout owned it. The comment on the alias reads “the OLD layout still serving at / reads this key. Remove when the preview is promoted.” It was added in its own commit, after the review pass found the live stockists page had lost its CSV.</li>
  <li><strong>The 404 page was rebuilt standalone</strong>, <code class="language-plaintext highlighter-rouge">layout: null</code>, its own markup and inline CSS, linking only home and the phone number. The CSS comment says why: “this page serves on the LIVE domain today, so it carries no nav into the unreleased design and no links that 404.” An earlier version inherited the redesign’s header, which meant any bad URL on the live domain leaked the unreleased site.</li>
</ol>

<p>One Liquid bug is worth recording because it looks correct. The header built the stockists link as:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">stk</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">page</span><span class="p">.</span><span class="nv">stockists_index</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">default</span><span class="p">:</span><span class="w"> </span><span class="nv">home</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">append</span><span class="p">:</span><span class="w"> </span><span class="s1">'stockists/'</span><span class="w"> </span><span class="cp">-%}</span>
</code></pre></div></div>

<p>Filters are a left-to-right pipeline, not a conditional. <code class="language-plaintext highlighter-rouge">default:</code> only substitutes when the left side is falsy, but <code class="language-plaintext highlighter-rouge">append:</code> runs regardless — so on a preview page where <code class="language-plaintext highlighter-rouge">page.stockists_index</code> was already <code class="language-plaintext highlighter-rouge">/preview/stockists/</code>, the link became <code class="language-plaintext highlighter-rouge">/preview/stockists/stockists/</code>. The fix is an explicit <code class="language-plaintext highlighter-rouge">unless</code>.</p>

<p>The flip itself, once he signed off, was mechanical: <code class="language-plaintext highlighter-rouge">git mv preview/stockists/* stockists/</code>, repoint <code class="language-plaintext highlighter-rouge">home_url</code> and <code class="language-plaintext highlighter-rouge">stockists_index</code>, drop the two gates, delete <code class="language-plaintext highlighter-rouge">_layouts/default.html</code> and the aliases only it read. One commit per site: 14 files, 45 insertions, 1,467 deletions on Superior Shavings; 47 and 1,932 on Breeder’s Choice. The design had been running on the real domain for two weeks, so there was nothing to discover at go-live.</p>

<p>Two things I learned from GitHub Pages in the same fortnight. It caches assets for four hours, which twice in one day left browsers running stale JS after a deploy; every asset URL on the bedding sites now carries <code class="language-plaintext highlighter-rouge">?v={{ site.time | date: '%s' }}</code>. And the Pages action injects a <code class="language-plaintext highlighter-rouge">base_path</code> that silently breaks every asset URL if the custom-domain setting is ever dropped, so the workflow pins <code class="language-plaintext highlighter-rouge">jekyll build --baseurl ""</code>. Google Analytics is emitted only when <code class="language-plaintext highlighter-rouge">jekyll.environment == "production"</code>, which the workflow sets; a dev build has zero gtag loads, production exactly one.</p>

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

<p>Mower Corner went live on 6 August and got the most functional work, because a shop has state a bedding manufacturer does not.</p>

<p>The header carries a live readout — “Open now · until 5pm”, or “Closed · opens tomorrow 9am” — computed in the shop’s timezone, not the visitor’s, so someone checking from interstate sees whether Colac is open right now. The hours live once, in <code class="language-plaintext highlighter-rouge">_config.yml</code>, and drive the schema.org <code class="language-plaintext highlighter-rouge">openingHoursSpecification</code>, the printed plate and the readout; the layout serialises them to <code class="language-plaintext highlighter-rouge">window.MC_HOURS</code> with <code class="language-plaintext highlighter-rouge">jsonify</code>. The JS uses <code class="language-plaintext highlighter-rouge">Intl.DateTimeFormat('en-AU', { timeZone: 'Australia/Melbourne', … }).formatToParts()</code>, normalises the hour with <code class="language-plaintext highlighter-rouge">% 24</code> because en-AU can render midnight as 24, strips the weekday to three letters because CLDR data varies, and wraps the whole thing in <code class="language-plaintext highlighter-rouge">try</code> — <code class="language-plaintext highlighter-rouge">formatToParts</code> with an explicit <code class="language-plaintext highlighter-rouge">timeZone</code> can throw on old engines, and a status readout is never worth a blank page. It refreshes every 60 s.</p>

<p>Four service pages were written against local search demand rather than invented: <code class="language-plaintext highlighter-rouge">chainsaw-service-sharpening</code>, <code class="language-plaintext highlighter-rouge">mower-servicing-repairs</code>, <code class="language-plaintext highlighter-rouge">spare-parts</code> and <code class="language-plaintext highlighter-rouge">pickup-and-delivery</code>. A pickup process I had drafted was removed the same day in a commit titled “remove the pickup process I invented, keep only what’s sourced”. The range picked up STIHL’s iMOW robotic mowers, and Click &amp; Collect is branded STIHL-only throughout, because the online-order pipeline is STIHL’s and implying you could get a Victa through it would generate exactly the wrong phone calls. <code class="language-plaintext highlighter-rouge">site.webmanifest</code> went from <code class="language-plaintext highlighter-rouge">display: standalone</code> to <code class="language-plaintext highlighter-rouge">browser</code>, because a brochure site for a local shop has no business prompting Chrome to install itself.</p>

<p>Two implementation notes I would defend anywhere:</p>

<ul>
  <li><strong>The mobile menu is a <code class="language-plaintext highlighter-rouge">&lt;details&gt;</code> disclosure.</strong> It opens and closes with no JavaScript. <code class="language-plaintext highlighter-rouge">mobileMenu()</code> is 28 lines and only adds Escape (returning focus to the <code class="language-plaintext highlighter-rouge">&lt;summary&gt;</code>), outside-click, closing on same-page anchors, and closing when the <code class="language-plaintext highlighter-rouge">(min-width: 62rem)</code> query flips to desktop — 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> at <code class="language-plaintext highlighter-rouge">threshold: 0.1</code>, staggered by <code class="language-plaintext highlighter-rouge">Math.min(i % 4, 3) * 70</code> ms. It checks <code class="language-plaintext highlighter-rouge">prefers-reduced-motion</code> first and, if the visitor has asked for less motion or the browser lacks the API, marks everything visible. The hiding styles are armed by <code class="language-plaintext highlighter-rouge">document.documentElement.classList.add('js')</code> from the script itself, not the document head: if the file never loads, nothing is ever hidden.</li>
</ul>

<p>The rebuild commit records two multi-agent review rounds over the code and 43 confirmed findings fixed, from ink-on-orange <code class="language-plaintext highlighter-rouge">::selection</code> at 5.4:1 to a duplicate canonical. What I would take from the four together: put the client’s data where the client already edits it, count what a regex drops before trusting it, and stage on the real domain behind <code class="language-plaintext highlighter-rouge">noindex</code> rather than on a lookalike. No Lighthouse numbers were recorded, so I am not quoting any.</p>]]></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 4.4: 1,843- and 1,380-line layouts replaced by 443 lines each, 947 stockists rendered as indexable HTML from a CSV the owner still edits in Dropbox, a two-pass state matcher that recovered 74 dropped rows, and a preview-on-the-live-domain pattern that let the client 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 (a small program that runs on Cloudflare’s servers), 3,054 lines of TypeScript, that takes an alert, investigates it against GitHub, Datadog, Sentry and Cloudflare’s own Worker telemetry, and reports what changed in one paragraph — or replies with the single word <code class="language-plaintext highlighter-rouge">NOTHING</code> and posts nothing. It exists because a health endpoint returned an HTTP 500 error whose body named the failing dependency for five hours, while the alert carried only <code class="language-plaintext highlighter-rouge">expected 200, got 500</code>. <strong>MIT licensed.</strong></p>

<p>This post walks through the mechanisms and the failures that shaped them: the ordered triage procedure in the prompt and the rule that a failed tool may never read as an all-clear; the three ways a Worker agent fails silently that I found by running it against live incidents (background work cancelled with no error, a bundle of static files answering “method not allowed” to every inbox, and a reasoning model using up its whole output budget before writing a word); the side-by-side model comparison that changed the default model; and the tool that found a stalled site eight earlier triage turns had called a test flake.</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="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.</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. 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-procedure-is-in-the-prompt-in-order">The procedure is in the prompt, in order</h2>

<p><code class="language-plaintext highlighter-rouge">OPS_SYSTEM_PROMPT</code> in <code class="language-plaintext highlighter-rouge">src/agent-ops.ts</code> is an ordered procedure, not a vibe:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>When you are handed an alert, work in this order:
  1. Confirm it is still failing. runCheckNow is cheap and stops you
     explaining an outage that has already passed.
  2. Look at the check history. A single blip and a sustained outage call for
     different responses.
  3. Look for what changed. Most outages follow a deploy, so recentDeploys on
     the relevant repository is usually the highest-value call.
  4. Look for corroboration: a new Sentry error that started inside the same
     window, or a Datadog metric that stepped rather than wobbled.
  5. Check whether this endpoint has failed before and what was concluded.
</code></pre></div></div>

<p>A turn may make at most twelve tool calls and run for at most 120 s, and each tool result is trimmed to 1,500 characters before it goes back to the model. Then it says what it found, citing the pull request number, the exception, the ratio. If the evidence does not support a cause it says the cause is unclear and lists what it ruled out — a confident wrong answer sends someone to the wrong service and costs more than an honest “unclear”.</p>

<p>The clause that took a real incident to write is this one:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NEVER report the absence of evidence as evidence of absence. If a tool errored,
returned nothing, or was not configured, say so in those words — "Sentry was
unreachable" is useful and "no new Sentry errors" is a lie that reads exactly
like an all-clear.
</code></pre></div></div>

<p>Findings worth keeping are written back with <code class="language-plaintext highlighter-rouge">annotateIncident</code> to the clawdwatch API at <code class="language-plaintext highlighter-rouge">MONITORING_URL</code>, authenticated with a Cloudflare Access service token. The signed per-incident action links described in the clawdwatch docs are not yet attached to alerts, so this is the standing credential thinkbot does hold — read scope on the sources, write on annotations.</p>

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

<p>If triage found nothing that explains the failure, the model replies <code class="language-plaintext highlighter-rouge">NOTHING</code> and the channel gets nothing. The return contract is <code class="language-plaintext highlighter-rouge">result.text.trim()</code>: an empty string means “nothing worth saying” and the caller decides whether that becomes silence or a log line. <code class="language-plaintext highlighter-rouge">triageAlert</code> logs <code class="language-plaintext highlighter-rouge">[alert] triage found nothing to report</code> and returns.</p>

<p>An agent that always produces a paragraph will always produce a paragraph, and under an incident someone is trying to read, filler is worse than absence. Getting this right meant saying it three times over: in the prompt, in the return contract, and in each channel’s decision to post. Chat is the one exception, on purpose — a person waiting on a thread cannot tell a silent bot from a broken one, so <code class="language-plaintext highlighter-rouge">answerChat</code> always replies, with “I could not finish that one” if it must.</p>

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

<p><strong>Service binding, preferred.</strong> A service binding is a direct call from one Worker to another on the same Cloudflare account, and clawdwatch calls the <code class="language-plaintext highlighter-rouge">AlertInbox</code> entry point that way:</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, no public endpoint.</p>

<p><strong>Signed webhook</strong>, for anything that cannot bind. <code class="language-plaintext highlighter-rouge">POST /hooks/clawdwatch</code> verifies a keyed hash (an HMAC) over the timestamp and the body, using clawdwatch’s own exported verification function and header names 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: <strong>a direct Worker-to-Worker call carries no signature.</strong> The <code class="language-plaintext highlighter-rouge">AlertInbox</code> comment says there is correspondingly no check there, that the check belongs to the HTTP adapter, and that <code class="language-plaintext highlighter-rouge">triageAlert</code> is written not to assume it ran.</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 GitHub Actions runner when the nightly browser test suite (Playwright) fails. A binding is not available — bindings are same-account only and a runner is not on the account — so this is a keyed hash under thinkbot’s own header names, <code class="language-plaintext highlighter-rouge">x-thinkbot-signature</code> and <code class="language-plaintext highlighter-rouge">x-thinkbot-timestamp</code>, with its own key, <code class="language-plaintext highlighter-rouge">E2E_WEBHOOK_SECRET</code>. That is a different key 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 not 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 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 <code class="language-plaintext highlighter-rouge">repo</code>, <code class="language-plaintext highlighter-rouge">sha</code>, <code class="language-plaintext highlighter-rouge">runUrl</code>, <code class="language-plaintext highlighter-rouge">loadError</code>, <code class="language-plaintext highlighter-rouge">failures</code>, <code class="language-plaintext highlighter-rouge">passed</code> and <code class="language-plaintext highlighter-rouge">skipped</code>, and the type guard checks only that <code class="language-plaintext highlighter-rouge">repo</code> and <code class="language-plaintext highlighter-rouge">sha</code> are strings — the signature proves key possession, not sensible content.</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 prompt branch says so in capitals: “E2E SUITE FAILED TO RUN … No tests executed, so this 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> There is no second notifier behind it and nobody opens the Actions tab. The headline is the floor; the paragraph is what gets added.</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 one delivery path and no way to tell whether it worked. 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 different code path proves nothing about the one a real failure takes — but the headline is <code class="language-plaintext highlighter-rouge">🧪 … E2E alert path probe — delivery works, nothing is wrong</code>, first and unmistakably, and the prompt short-circuits to “Reply with the single word NOTHING. Do not investigate.”</p>

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

<h2 id="the-first-real-report-answered-200-and-posted-nothing">The first real report answered 200 and posted nothing</h2>

<p>The probe worked. Then the first real failure came through: <code class="language-plaintext highlighter-rouge">200</code> to the runner, no error in the logs, nothing in Slack, fifteen minutes on. The difference was that a probe’s model turn returns immediately and a real one makes tool calls.</p>

<p>The message was built as headline plus finding, inside the background work the Worker is allowed to keep doing after it has replied (<code class="language-plaintext highlighter-rouge">waitUntil</code>). So a slow turn swallowed the alert. The first fix split them: post the headline in the request itself — one Slack call, milliseconds — and let the finding follow as a second message. Worst case became a headline with no explanation, never an explanation nobody receives.</p>

<p>That was not the whole bug. Running recorded reports through the live deployment, one turn finished in 28 s and the rest were killed:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>waitUntil() tasks did not complete within the allowed time after invocation
end and have been cancelled.
</code></pre></div></div>

<p>Background work gets about thirty seconds after the reply. A triage turn that makes four or five tool calls regularly needs more, and the runtime cancels it with no error and no message. The monitoring inbox had the same shape, which means clawdwatch triage had been dropping silently whenever it ran long — invisible by construction, because the thing that failed was the thing that would have told you.</p>

<p><img src="/images/blog/thinkbot-waituntil-timeline.svg" alt="Two timelines. Before: the Worker replies to the runner at once and keeps triaging in the background; the platform allows about thirty seconds after the reply, so a 28-second turn finishes but a 40-second turn is cancelled with no error and no message, and the finding is never posted. After: the reply posts a plain headline immediately, and the triage turn runs as a separate queue job with its own budget of 120 seconds, so the finding follows as a second message." /></p>

<p>Every model turn now runs as a job on a Cloudflare queue instead:</p>

<div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="w">  </span><span class="nl">"queues"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"producers"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="w"> </span><span class="nl">"queue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"thinkbot-triage"</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">"TRIAGE_QUEUE"</span><span class="w"> </span><span class="p">}],</span><span class="w">
    </span><span class="nl">"consumers"</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">"queue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"thinkbot-triage"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"max_batch_size"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
        </span><span class="nl">"max_batch_timeout"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
        </span><span class="nl">"max_retries"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
        </span><span class="nl">"dead_letter_queue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"thinkbot-triage-dlq"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="err">,</span><span class="w">
</span></code></pre></div></div>

<p>Retries are off and every job is acked, including failed ones, because a retried turn may already have posted its finding, and a duplicate explanation under an incident someone is reading is worse than the missing one it recovers. The turn stays bounded at 120 s, below the consumer’s budget, so a hang leaves a log line saying so — the difference between “found nothing” and “never finished”.</p>

<p>Moving to a queue had a knock-on: everything an inbox receives has to survive being written out as JSON. The channel parsers used to hand back a reply function that remembered the Slack thread or Telegram chat it belonged to, and a function cannot go in a queue message. It is now a plain description of where to reply (the channel and either the Slack conversation and thread or the Telegram chat id), with a test that pins the one property that matters, that it survives the round trip through JSON.</p>

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

<p>thinkbot holds a GitHub personal access token, 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 route verifies its caller before doing any work. That was true of the routes and still not true of the deployment.</p>

<p>The Worker was scaffolded from Cloudflare’s agent starter template with a React chat page, and even after the page was disabled, <code class="language-plaintext highlighter-rouge">deploy</code> still built it and uploaded the bundle of static files. <strong>Static files are served before the Worker’s code runs.</strong> So the bundle answered requests no route guard ever saw, and that single fact produced three bugs:</p>

<ul>
  <li>an unauthenticated <code class="language-plaintext highlighter-rouge">GET</code> to the hostname returned the chat page;</li>
  <li><code class="language-plaintext highlighter-rouge">/health</code> answered <code class="language-plaintext highlighter-rouge">200</code> with the chat page’s HTML instead of the health handler — a liveness endpoint that cannot fail, reporting healthy through the outage it exists to catch;</li>
  <li>every signed inbox returned <code class="language-plaintext highlighter-rouge">405</code>, “method not allowed”, because the static file handler rejects anything but <code class="language-plaintext highlighter-rouge">GET</code> rather than passing it on to the Worker. Verified against the live deployment: <code class="language-plaintext highlighter-rouge">POST</code> to <code class="language-plaintext highlighter-rouge">/hooks/clawdwatch</code>, <code class="language-plaintext highlighter-rouge">/hooks/slack</code> and <code class="language-plaintext highlighter-rouge">/health</code> alike. Monitoring never noticed, because it reaches thinkbot over the direct binding, which does not pass through the static files.</li>
</ul>

<p>The first fix listed the paths that should reach the Worker first (<code class="language-plaintext highlighter-rouge">"run_worker_first": ["/agents/*", "/oauth/*", "/hooks/*"]</code>), then added <code class="language-plaintext highlighter-rouge">/health</code> to the list. That moves the boundary and leaves the rest in place. The real fix is having no static files at all: with the bundle gone, every request lands on the code that checks its caller. Deleting the chat page also dropped React, Tailwind, the Vite build and eleven packages; removing the now-unused chat agent’s Durable Object (Cloudflare’s single-instance stateful object) took two more packages with it, and the Worker bundle went from 2.8 MB to 1.2 MB.</p>

<p>One local trap, recorded only in the commit message and worth knowing if you mix Vite (the front-end build tool) and Wrangler (Cloudflare’s deploy tool): the front-end build writes a small config file under <code class="language-plaintext highlighter-rouge">.wrangler/deploy/</code> that points Wrangler at the built <code class="language-plaintext highlighter-rouge">dist/</code> folder instead of your source. Both are ignored by git, so a stale redirect keeps deploying the old bundle while ignoring your edits to <code class="language-plaintext highlighter-rouge">wrangler.jsonc</code>. Remove <code class="language-plaintext highlighter-rouge">.wrangler/deploy</code> and <code class="language-plaintext highlighter-rouge">dist</code> before the next deploy.</p>

<h2 id="the-model-posted-256-exclamation-marks">The model posted 256 exclamation marks</h2>

<p><code class="language-plaintext highlighter-rouge">npm test</code> mocks the model — 192 tests, 469 ms, free and offline. That is the right default, and it means the whole suite goes green whatever the model emits. On 2026-08-22 a collapsed generation put 256 exclamation marks into the alert channel under a live headline about three failing specs, where it read as the alerting itself having broken.</p>

<p>Nothing sat between the model’s output and Slack but an empty-string check and the word <code class="language-plaintext highlighter-rouge">NOTHING</code>. Now <code class="language-plaintext highlighter-rouge">usableFinding()</code> in <code class="language-plaintext highlighter-rouge">src/triage-output.ts</code> judges the output on properties, not quality: one character making up more than 40% of it, letters making up less than 35% of it, or fewer than 24 characters in total, and in each case it prefers silence. It cannot separate a correct diagnosis from a confident wrong one; no rule can.</p>

<p>The wall was exactly 256 characters, and <code class="language-plaintext highlighter-rouge">!</code> is one token. That is Workers AI’s default completion cap, not a content limit, so <code class="language-plaintext highlighter-rouge">maxOutputTokens</code> is now set explicitly — which makes a collapse <em>longer</em>, and is only safe because the guard drops it first.</p>

<p>To exercise the model half at all, <code class="language-plaintext highlighter-rouge">POST /hooks/e2e/dry-run</code> runs the same prompt through the same turn behind the same signature and returns the result instead of posting it, and <code class="language-plaintext highlighter-rouge">test/fixtures/</code> holds real payloads recovered from the workflow logs of the runs that produced them. Invented reports correlate with nothing. The integration test’s header is explicit about what it can check: that the output is prose, finished, hedged when the evidence is thin, and silent on a probe. Not that the diagnosis is right.</p>

<h2 id="two-models-on-the-same-incident-and-what-neither-found">Two models on the same incident, and what neither found</h2>

<p><code class="language-plaintext highlighter-rouge">scripts/triage-harness.mjs</code> runs two models against the same recorded report. On 2026-08-22, six turns each:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th><code class="language-plaintext highlighter-rouge">gpt-oss-120b</code></th>
      <th><code class="language-plaintext highlighter-rouge">kimi-k2.6</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Collapsed into exclamation marks</td>
      <td>once in six turns</td>
      <td>never</td>
    </tr>
    <tr>
      <td>When a tool had errored</td>
      <td>wrote “no new Sentry/Rollbar errors”, a made-up all-clear</td>
      <td>said the tool was inaccessible</td>
    </tr>
    <tr>
      <td>Named the test framework</td>
      <td>called Playwright “Cypress”</td>
      <td>correctly</td>
    </tr>
    <tr>
      <td>Tool calls to reach an answer</td>
      <td>9 to 11</td>
      <td>3 to 5</td>
    </tr>
    <tr>
      <td>Time per turn</td>
      <td>20 to 32 s</td>
      <td>46 to 74 s</td>
    </tr>
  </tbody>
</table>

<p>A made-up negative is indistinguishable in an incident channel from a real all-clear, which decided it. The slower model is about twice as slow per turn, and this runs on a nightly, so the cost is nothing. <code class="language-plaintext highlighter-rouge">DEFAULT_MODEL</code> is <code class="language-plaintext highlighter-rouge">@cf/moonshotai/kimi-k2.6</code> as of that run.</p>

<p>What the comparison did not show is either model finding the cause. Both concluded “test flake, site healthy” about an incident that was a server-side render stall, because no tool reached the place the evidence was. A better model would have been confidently wrong too.</p>

<h2 id="the-one-source-that-would-have-been-right">The one source that would have been right</h2>

<p>The site was stalling for 40–60 s on roughly a third of cold renders and truncating the HTML stream. Every source the agent could reach agreed it was healthy: nothing merged in the window, no new Sentry issue, no Datadog alert, and the uptime checks — which fetch a page and read the first byte — were green. A response that starts in 100 ms and never finishes is invisible to all of them. In the Worker’s own invocation telemetry it reads:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wallTime 59971ms, cpuTime 117ms, outcome canceled
</code></pre></div></div>

<p>A minute of elapsed time against a tenth of a second of processor time can only be a request stuck waiting on something else. So <code class="language-plaintext highlighter-rouge">workerHealth</code> in <code class="language-plaintext highlighter-rouge">src/tools/workers.ts</code> queries Cloudflare’s Worker telemetry and states the verdict rather than leaving it as arithmetic:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">STALL_WALL_MS</span> <span class="o">=</span> <span class="mi">10</span><span class="nx">_000</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">STALL_RATIO</span> <span class="o">=</span> <span class="mi">20</span><span class="p">;</span>
</code></pre></div></div>

<p>Over the real incident window it returns <code class="language-plaintext highlighter-rouge">STALLED: p99 wall 52572ms against p99 CPU 1172ms — 45:1. That is a request waiting on I/O that never returns, NOT slow code.</code> (the “p99” figures are the slowest one per cent of requests). Over the window after the fix: <code class="language-plaintext highlighter-rouge">Healthy</code>, with the slowest one per cent taking 6,538 ms. It returns totals and percentiles only, never log lines, which run to thousands a minute and carry request URLs and headers you then pay for in context on every later turn. Its first version asked the API for <code class="language-plaintext highlighter-rouge">p50</code>, which Cloudflare rejects with a failure flag and an empty error list, so it failed silently while every unit test passed; <code class="language-plaintext highlighter-rouge">median</code> is the supported spelling, and <code class="language-plaintext highlighter-rouge">workers.itest.ts</code> drives the real API to catch the next rename.</p>

<p>With the window supplied, triage reached the conclusion eight earlier turns had missed: “requests are starting and then stalling on I/O, not slow code. The uptime checks are all green because they measure first byte only. This is a failing site, not a failing test.”</p>

<h2 id="the-empty-turns-were-the-token-ceiling">The empty turns were the token ceiling</h2>

<p>Even with the right tool, two runs in three did the most work — GitHub, Datadog, Worker telemetry across four or five steps — and then ended without writing the paragraph. Two fixes were reasoned to rather than measured: the first assumed a conversation-continuation problem, the second a malformed tool-call exchange. Both were plausible. Each was declared a fix on a sample too small to show otherwise.</p>

<p>Logging <code class="language-plaintext highlighter-rouge">finishReason</code> and <code class="language-plaintext highlighter-rouge">usage</code> settled it in one run:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>finishReason=stop    outputTokens=153     1 turn
finishReason=length  outputTokens=2048    4 turns, both retries included
</code></pre></div></div>

<p>2,048 was the output budget. A reasoning model was spending the entire budget thinking and had nothing left to write, and the retry hit the same wall. Raised to 8,192: five runs after, five of five answered, no empty turns. The galling detail, now recorded beside <code class="language-plaintext highlighter-rouge">MAX_OUTPUT_TOKENS</code> so the number is never guessed at again: the comment on the old value already said the headroom was for “a reasoning model’s hidden tokens”. Right worry, wrong number.</p>

<h2 id="a-tool-that-cannot-answer-costs-a-step-and-a-sentence">A tool that cannot answer costs a step and a sentence</h2>

<p>This deployment carried a Rollbar tool with an invalid token for months. Every triage turn spent one of its twelve steps to be told “forbidden”, and once the prompt required reporting sources that failed, every write-up carried “Rollbar could not be checked” as though it were a finding. Rollbar left the estate on 2026-08-22 and the tool went with it.</p>

<p>The general fix is structural. <code class="language-plaintext highlighter-rouge">src/tools/registry.ts</code> declares each provider as a name, the env vars it cannot work without, and a builder; a provider missing any of them is not offered at all — not offered and failing, not offered. <code class="language-plaintext highlighter-rouge">GITHUB_OWNER</code> and <code class="language-plaintext highlighter-rouge">SENTRY_ORG</code> have no defaults for the same reason a default would mean an unconfigured deployment quietly querying somebody else’s organisation. 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 and read by a model, not parsed. Baking one organisation’s inventory into the prompt is what makes a general tool unusable by anyone else, and stale for its owner the first time the estate changes.</p>

<h2 id="keep-the-dumb-notifier">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 agent is the only alert path, an agent outage is a monitoring outage, and every failure above — the cancelled background work, the “method not allowed” from the static files, the exhausted token budget — would have been one. A failing notifier cannot affect the other, so the plain, model-free Slack message always goes out. If you take one idea from either project, take that one.</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 <code class="language-plaintext highlighter-rouge">wrangler queues create thinkbot-triage-dlq</code> and <code class="language-plaintext highlighter-rouge">thinkbot-triage</code>, 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>

<p>What building it taught me, in order of cost: measure before diagnosing, because two of the fixes above were reasoned to and wrong; an agent’s worst failures are silent ones, so every path needs a floor that does not depend on the model; and the tool set should be the estate’s, not the author’s. The next open item is on the clawdwatch side — attaching the signed action links so <code class="language-plaintext highlighter-rouge">annotateIncident</code> can drop its standing credential.</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 Worker telemetry, and says what changed in one paragraph — or the single word NOTHING. Also: how waitUntil silently cancelled every triage that ran long, why a static asset bundle was a second front door, and the token ceiling that two 'fixes' reasoned past.]]></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-09-18T00: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. The first instrument pack is didgeridoo, because I snore, and a randomised controlled trial says four months of didgeridoo practice roughly halves obstructive sleep apnoea. The trial’s dose was at least 20 minutes a day, five days a week, for four months — about 120 sessions — and the pack encodes that as 16 weeks in four phases, with the browser’s microphone checking that the 20 minutes were spent actually droning. It is 4,352 lines of TypeScript across five packages, runs entirely in the browser with no server, and is <strong>MIT licensed.</strong></p>

<p>This post walks through the parts I had to get wrong first: why the microphone has to be opened with WebRTC’s processing switched off, why pitch comes from a 4096-sample autocorrelation window and not an FFT, the 400 ms onset and 250 ms grace that stop a wobble ending your record, how a “perfect week” is prorated when you join on a Wednesday, and what the three.js lane does at 4 s, 3.2 s and 9 s.</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="the-intervention-is-turning-up-120-times">The intervention is turning up 120 times</h2>

<p>I played trombone at a decent level and then stopped for years. I also snore, 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 makes people assume the science is a joke. 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.</p>

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

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

<h2 id="what-already-existed">What already existed</h2>

<p>Two things, worth knowing before reading mine. <strong><a href="https://silentsleep.training/">Silent Sleep Training</a></strong> is built by Alex Suarez’s company — Suarez taught the participants in the BMJ trial — and bundles a silicone instrument with an app that listens to a 15-minute daily session. <strong><a href="https://didgeforsleep.com/">Didge For Sleep</a></strong> is a real travel didgeridoo plus an apnoea-specific video curriculum, with no app and no tracking.</p>

<p>What did not exist was the overlap: a real didgeridoo, a 30–40 minute guided session, and a microphone that verifies you played. The mic-verified music trainers (Yousician, tonestro) need pitched repertoire and cannot score an unpitched drone. A GitHub search for a didgeridoo or circular-breathing trainer returns an acoustic-impedance calculator and 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="the-instrument-already-keeps-score">The instrument already keeps score</h2>

<p>The easy version of this app is a Duolingo skin: streaks, XP, a celebration when the bar fills. I did not build that, because the didgeridoo already keeps score honestly. Your longest unbroken drone is a real number about your actual throat, and wrapping fake currency around a real measurement does not 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, and it is implemented as three mechanisms in <code class="language-plaintext highlighter-rouge">packages/core</code>.</p>

<p><strong>Records, not scores.</strong> The pack declares two metrics, <code class="language-plaintext highlighter-rouge">longest-drone</code> and <code class="language-plaintext highlighter-rouge">longest-unbroken</code>, both <code class="language-plaintext highlighter-rouge">direction: 'higher'</code>. A record is a <code class="language-plaintext highlighter-rouge">{ value, date, verified }</code> entry; <code class="language-plaintext highlighter-rouge">verified</code> is whether a microphone measured it or you typed it. The test in <code class="language-plaintext highlighter-rouge">core.test.ts</code> records 12, then 10, then 20: the first and third are personal records, the second is not, and the third reports <code class="language-plaintext highlighter-rouge">previousBest: 12</code>.</p>

<p><strong>Unlock by demonstration.</strong> A drill lists prerequisites and <code class="language-plaintext highlighter-rouge">unlockedDrills()</code> in <code class="language-plaintext highlighter-rouge">unlocks.ts</code> is the whole gate:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="k">case</span> <span class="dl">'</span><span class="s1">metric</span><span class="dl">'</span><span class="p">:</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">best</span> <span class="o">=</span> <span class="nx">state</span><span class="p">.</span><span class="nx">bests</span><span class="p">[</span><span class="nx">req</span><span class="p">.</span><span class="nx">metric</span><span class="p">];</span>
      <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">best</span><span class="p">)</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
      <span class="kd">const</span> <span class="nx">def</span> <span class="o">=</span> <span class="nx">pack</span><span class="p">.</span><span class="nx">metrics</span><span class="p">.</span><span class="nf">find</span><span class="p">((</span><span class="nx">m</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">m</span><span class="p">.</span><span class="nx">id</span> <span class="o">===</span> <span class="nx">req</span><span class="p">.</span><span class="nx">metric</span><span class="p">);</span>
      <span class="k">return</span> <span class="nx">def</span><span class="p">?.</span><span class="nx">direction</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">lower</span><span class="dl">'</span> <span class="p">?</span> <span class="nx">best</span><span class="p">.</span><span class="nx">value</span> <span class="o">&lt;=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">gte</span> <span class="p">:</span> <span class="nx">best</span><span class="p">.</span><span class="nx">value</span> <span class="o">&gt;=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">gte</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">case</span> <span class="dl">'</span><span class="s1">self-report</span><span class="dl">'</span><span class="p">:</span>
      <span class="k">return</span> <span class="nx">state</span><span class="p">.</span><span class="nx">selfReports</span><span class="p">[</span><span class="nx">req</span><span class="p">.</span><span class="nx">id</span><span class="p">]</span> <span class="o">===</span> <span class="kc">true</span><span class="p">;</span>
    <span class="k">case</span> <span class="dl">'</span><span class="s1">drill-completed</span><span class="dl">'</span><span class="p">:</span>
      <span class="k">return </span><span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">drillCompletions</span><span class="p">[</span><span class="nx">req</span><span class="p">.</span><span class="nx">drill</span><span class="p">]</span> <span class="o">??</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">times</span> <span class="o">??</span> <span class="mi">1</span><span class="p">);</span>
</code></pre></div></div>

<p>The circular-breathing chain in the didgeridoo pack runs in a fixed order: cheek-puff breathing (three demonstrations), then water spray (two), then straw and glass (three, plus a self-report: “Can you keep the straw bubbles unbroken through a sniff?”), then circular breathing on the didgeridoo (two), then bridging the gap. After that, the unbroken-sets drill needs something measured rather than reported: a single unbroken note of 60 seconds. When nothing has been demonstrated yet, the session compiler does not skip the gate; it walks back down the chain, and the week-six test asserts that the session contains cheek-puff breathing and not circular breathing on the didgeridoo.</p>

<p><img src="/images/blog/sustain-breathing-chain.svg" alt="The circular-breathing drill chain in order: cheek-puff breathing three times, water spray twice, straw and glass three times plus a self-report, circular breathing on the didgeridoo twice, bridge the gap, and finally unbroken sets, which needs a measured 60-second note. Each drill unlocks only when the one before it has been demonstrated." /></p>

<p><strong>Perfect weeks, not daily streaks.</strong> The evidence protocol is five days a week, so rest days are scheduled, not failures; the <a href="https://onlinelibrary.wiley.com/doi/10.1002/ejsp.674">habit-formation work</a> found a single missed day does not impair habit formation. Two details in <code class="language-plaintext highlighter-rouge">adherence.ts</code> cost more thought than the rest of the file:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="c1">// The join week only asks for the practice days that were still ahead.</span>
    <span class="kd">let</span> <span class="nx">target</span> <span class="o">=</span> <span class="nx">fullTarget</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">wk</span> <span class="o">===</span> <span class="nf">mondayOf</span><span class="p">(</span><span class="nx">join</span><span class="p">))</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">missedDays</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">min</span><span class="p">(</span><span class="nf">daysBetween</span><span class="p">(</span><span class="nx">wk</span><span class="p">,</span> <span class="nx">join</span><span class="p">),</span> <span class="nx">fullTarget</span><span class="p">);</span>
      <span class="nx">target</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">max</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="nx">fullTarget</span> <span class="o">-</span> <span class="nx">missedDays</span><span class="p">);</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>Join on a Wednesday, practise Wednesday to Friday, and the test asserts <code class="language-plaintext highlighter-rouge">target</code> is 3, the week is perfect, and the streak is 1. Nothing is more discouraging than a program that opens by scoring you against days that had gone by before you downloaded it.</p>

<p>The second is the make-up: <code class="language-plaintext highlighter-rouge">compileSession()</code> returns <code class="language-plaintext highlighter-rouge">null</code> on a rest day unless called with <code class="language-plaintext highlighter-rouge">{ makeup: true }</code>, and the adherence test practises Monday to Thursday, misses Friday, plays Saturday, and asserts the week is perfect. Nearly every habit app treats the miss as the punishable event. The thing worth protecting is the return.</p>

<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 is the sixteen-week journey, one bar per week, height being sessions completed and colour the phase. It is the only “gamified” element and it is a picture of the truth.</p>

<h2 id="a-session-is-a-lane">A session is a lane</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 in a three.js night scene — 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>The whole scene is one 449-line class, <code class="language-plaintext highlighter-rouge">PracticeScene</code> in <code class="language-plaintext highlighter-rouge">apps/web/src/scene/scene.ts</code>. A segment is <code class="language-plaintext highlighter-rouge">seg.seconds * SCALE</code> units long with <code class="language-plaintext highlighter-rouge">SCALE = 0.55</code>, playing segments are 0.42 units tall and the full 2.2-unit lane width, rests are 0.1 tall and 45% width, and the lane group is translated by <code class="language-plaintext highlighter-rouge">elapsed * SCALE</code> each frame so the strike point never moves. Most of the rest is borrowed from software that solved these problems years ago:</p>

<ul>
  <li><strong>The next segment brightens</strong> when <code class="language-plaintext highlighter-rouge">remaining &lt; 4</code> seconds, ramping its opacity linearly over the 4 s — telegraphing, not a countdown. The audio pre-cue chime is separate and earlier-tuned: it fires at 3.2 s, and only for segments of 10 s or more, so a run of short rests does not chime continuously.</li>
  <li><strong>Rests get a breathing halo</strong> on an 8 s cycle, <code class="language-plaintext highlighter-rouge">0.5 - 0.5 * Math.cos((t % 8) * (2π / 8))</code>, paced like the Apple Watch Breathe animation, because in a didgeridoo program the rests are breathing exercises. Under <code class="language-plaintext highlighter-rouge">prefers-reduced-motion</code> it holds still.</li>
  <li><strong>The HUD fades</strong> after 9,000 ms without a <code class="language-plaintext highlighter-rouge">pointermove</code>, <code class="language-plaintext highlighter-rouge">pointerdown</code> or <code class="language-plaintext highlighter-rouge">keydown</code>, by adding a <code class="language-plaintext highlighter-rouge">quiet</code> class; any input removes it.</li>
  <li><strong>The played trail</strong> rides behind the strike point, a fill mesh whose <code class="language-plaintext highlighter-rouge">scale.z</code> grows with progress: bright where the drone lived, dim where it dropped.</li>
</ul>

<p><img src="/images/blog/sustain-segment-cues.svg" alt="Cues before the next segment arrives: the next segment sits dim, a chime sounds 3.2 seconds before it, it brightens steadily over the last four seconds, and the strike point is when it begins; the chime only plays for segments of ten seconds or more." /></p>

<p>One thing I got wrong and fixed in a nine-line commit: the camera was at <code class="language-plaintext highlighter-rouge">(0, 2.4, 4.6)</code> looking at <code class="language-plaintext highlighter-rouge">(0, 0.5, -7)</code>, and from there the upcoming segments stacked into each other. Raising it to <code class="language-plaintext highlighter-rouge">(0, 3.1, 4.4)</code> and looking further down the lane at <code class="language-plaintext highlighter-rouge">(0, 0.35, -8)</code> made the lane ahead readable. Same commit dropped the bloom multiplier from <code class="language-plaintext highlighter-rouge">0.8</code> to <code class="language-plaintext highlighter-rouge">0.5</code>; the ember was flaring the whole scene.</p>

<h2 id="listening-without-a-server">Listening without a server</h2>

<p>Verification is tiered, and the app is fully usable at tier one. The engine consumes a <code class="language-plaintext highlighter-rouge">PracticeSource</code> and does not care which tier produced the frames:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code> <span class="o">*</span>  <span class="o">-</span> <span class="dl">'</span><span class="s1">timer</span><span class="dl">'</span>  <span class="err">—</span> <span class="nx">always</span> <span class="nx">playing</span> <span class="k">while</span> <span class="nf">running </span><span class="p">(</span><span class="nx">honor</span> <span class="nx">system</span><span class="p">,</span> <span class="nx">no</span> <span class="nx">mic</span><span class="p">)</span>
 <span class="o">*</span>  <span class="o">-</span> <span class="dl">'</span><span class="s1">energy</span><span class="dl">'</span> <span class="err">—</span> <span class="nx">mic</span> <span class="nx">loudness</span> <span class="nf">gate </span><span class="p">(</span><span class="nx">reactive</span> <span class="nx">visuals</span><span class="p">,</span> <span class="nx">coarse</span> <span class="nx">credit</span><span class="p">)</span>
 <span class="o">*</span>  <span class="o">-</span> <span class="dl">'</span><span class="s1">pitch</span><span class="dl">'</span>  <span class="err">—</span> <span class="nx">pitch</span><span class="o">-</span><span class="nx">verified</span> <span class="nf">playing </span><span class="p">(</span><span class="nx">auto</span><span class="o">-</span><span class="nx">measured</span> <span class="nx">metrics</span><span class="p">)</span>
</code></pre></div></div>

<p>The timer is the default until the mic is granted; it is how the BMJ trial ran, with a paper diary. The energy tier is an eight-line class over an adaptive noise floor — <code class="language-plaintext highlighter-rouge">gate()</code> is <code class="language-plaintext highlighter-rouge">max(0.01, noiseFloor * 3)</code>, and the floor is learned only from frames that are already quiet. The pitch tier is where the work was.</p>

<p><strong>The browser fights you.</strong> Opening the microphone with default constraints silently attenuates a drone. <code class="language-plaintext highlighter-rouge">mic.ts</code> explains why in the comment above the call:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/**
 * Open the mic with browser audio processing disabled. WebRTC noise
 * suppression classifies sustained tones as background noise and attenuates
 * them, and auto-gain pumps a continuous drone — both must be off.
 */</span>
</code></pre></div></div>

<p>So it is <code class="language-plaintext highlighter-rouge">echoCancellation: false, noiseSuppression: false, autoGainControl: false</code>, all three.</p>

<p><strong>Not an FFT.</strong> Pitch is <a href="https://www.npmjs.com/package/pitchy">pitchy</a>’s McLeod Pitch Method, an autocorrelation-family detector, over a time-domain buffer:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">WINDOW</span> <span class="o">=</span> <span class="mi">4096</span><span class="p">;</span> <span class="c1">// ~85 ms at 48 kHz — 5-7 periods of a 60-90 Hz drone</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">findPitch</code> returns a frequency and a clarity, and a frame counts as playing when <code class="language-plaintext highlighter-rouge">clarity &gt;= minClarity &amp;&amp; pitchHz &gt;= minHz &amp;&amp; pitchHz &lt;= maxHz</code>. The thresholds come from the pack, not the detector, and the didgeridoo pack sets <code class="language-plaintext highlighter-rouge">minHz: 50, maxHz: 100, minClarity: 0.8, stabilitySemitones: 3</code>, with a comment that a beginner’s drone wobbles and too strict a gate reads as “the app can’t hear me”. The 50–100 Hz band is what keeps speech and room noise out, so the other two can afford to be forgiving. Two further gates: over any 1 s window with at least five samples, a pitch spread wider than 3 semitones is not a drone; and a reading within 1 Hz of 50 or 60 Hz at RMS below 0.02 is mains hum, not you.</p>

<p><strong>Hysteresis lives in the clock, not the detector.</strong> <code class="language-plaintext highlighter-rouge">PlayingClock</code> in <code class="language-plaintext highlighter-rouge">clock.ts</code> has two constants, <code class="language-plaintext highlighter-rouge">onsetMs = 400</code> and <code class="language-plaintext highlighter-rouge">graceMs = 250</code>. A frame stream has to be “playing” for 400 ms before the clock starts, and then the whole qualifying window is credited retroactively — you were playing during it, the clock was just waiting to be sure. Once running, a gap shorter than 250 ms keeps crediting through the wobble. Per-frame credit is capped at 500 ms and a gap over 2,000 ms is treated as a discontinuity (a backgrounded tab), so a stall cannot mint minutes. Swapping sources resets the clocks, so timer time can never be reported as verified.</p>

<p><img src="/images/blog/sustain-playing-clock.svg" alt="Frame by frame: the detector hears a drone, the clock starts only after 400 ms of it and then credits those 400 ms back; a wobble of under 250 ms is bridged; a gap over two seconds is a break, with no credit and the clock waiting 400 ms again. Each frame can credit at most 500 ms, so a stalled tab cannot mint minutes." /></p>

<p>All of it runs in the browser. Progress and recordings live in two IndexedDB object stores, <code class="language-plaintext highlighter-rouge">progress</code> and <code class="language-plaintext highlighter-rouge">recordings</code>, through a 79-line hand-rolled wrapper — the web app’s only runtime dependency is <code class="language-plaintext highlighter-rouge">three</code>. There is no account, no server and no telemetry. The network requests the app makes are to Google Fonts, for Fraunces and Karla; nothing else.</p>

<h2 id="packs-are-data">Packs are data</h2>

<p>An instrument pack is metrics, a curriculum of phases and drills with prerequisites, and an analyzer spec. The engine’s entry point is one function:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">compileSession</span><span class="p">(</span>
  <span class="nx">pack</span><span class="p">:</span> <span class="nx">InstrumentPack</span><span class="p">,</span>
  <span class="nx">day</span><span class="p">:</span> <span class="nx">ProgramDay</span><span class="p">,</span>
  <span class="nx">unlockedDrillIds</span><span class="p">:</span> <span class="nb">Set</span><span class="o">&lt;</span><span class="kr">string</span><span class="o">&gt;</span><span class="p">,</span>
  <span class="nx">options</span><span class="p">:</span> <span class="nx">CompileOptions</span> <span class="o">=</span> <span class="p">{},</span>
<span class="p">):</span> <span class="nx">CompiledSession</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
</code></pre></div></div>

<p>Session length ramps across a phase by linear interpolation on <code class="language-plaintext highlighter-rouge">phaseProgress</code>, which is week position within the phase’s <code class="language-plaintext highlighter-rouge">[start, end]</code>. The didgeridoo pack is <code class="language-plaintext highlighter-rouge">daysPerWeek: 5, totalWeeks: 16</code> in four phases — Foundation (weeks 1–2), Breath mechanics (3–5), Connection (6–9), Endurance &amp; voice (10–16) — each closed by a measured assessment on the phase’s last practice day: 120 s per metric plus 45 s rest. The third one is named “The loop” and its subtitle is “Sixty seconds is the gate.”</p>

<p>The useful accident is that the pitch analyzer is instrument-agnostic. A trombone pack is the same detector with a different <code class="language-plaintext highlighter-rouge">minHz</code>/<code class="language-plaintext highlighter-rouge">maxHz</code>, and gets more out of it, because a detector that reports hertz can measure long-tone steadiness in cents. Guitar needs a polyphonic analyzer, which is a different problem, and is marked help-wanted rather than pretended at.</p>

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

<p><code class="language-plaintext highlighter-rouge">pnpm -r test</code> is green: 42 tests (17 in <code class="language-plaintext highlighter-rouge">core</code>, 13 in <code class="language-plaintext highlighter-rouge">apps/web</code>, 5 each in <code class="language-plaintext highlighter-rouge">audio</code> and <code class="language-plaintext highlighter-rouge">pack-sdk</code>, 2 in the didgeridoo pack) cover the engine, the clock, and the web app’s storage; the scene is checked by looking at it. The pack’s <code class="language-plaintext highlighter-rouge">baseline-recording</code> on day one and <code class="language-plaintext highlighter-rouge">summit-recording</code> at week 16, both 30 s of <code class="language-plaintext highlighter-rouge">MediaRecorder</code> keyed <code class="language-plaintext highlighter-rouge">${pack.id}:${sessionDate}:${drillId}</code>, play back to back from a Compare view on the home screen — earliest day one, latest summit, and “not recorded yet, week N of 16” until the summit exists. Export is a versioned bundle, <code class="language-plaintext highlighter-rouge">{ version: 1, exportedAt, progress, recordings }</code>, one JSON file with every recording inline as base64, and import validates the version and the pack, writes the recordings back into IndexedDB, and will not replace existing progress without a confirmation. The engine, the didgeridoo pack, all three microphone tiers and the web app work end to end, and 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 a PVC didgeridoo from <a href="https://africandrumming.com.au/">African Drumming</a>. The BMJ trial handed its participants plastic instruments because they are easier to learn on, and the effect comes from what your airway is doing, not what the tube is made of.</p>

<p>So there is a curriculum, a coach, an instrument, and an empty progress file. What I learned building it: the browser’s audio defaults are tuned to remove exactly the signal a drone is, the clock needs more care than the detector, and the hard part of a habit app is the week you join and the day you miss. The day-one recording is on disk, and the summit will play back beside it when it lands. The trombone pack is next. 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. The didgeridoo pack encodes the BMJ sleep-apnoea trial's protocol as 16 weeks in four phases; the browser verifies you are really droning with a 4096-sample McLeod pitch detector and a 400 ms / 250 ms hysteresis clock; progress is records and prorated perfect weeks, not points. Local-first, no server, MIT.]]></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 small 3D renderer for annotated technical drawings that outputs plain SVG strings. The core in <code class="language-plaintext highlighter-rouge">src/linework.ts</code> is 171 lines with zero runtime dependencies. Hand it 3D points, or a <strong>glTF, OBJ or STL mesh</strong> (three common 3D model formats), and it rotates the geometry, projects it with perspective, sorts it back to front every frame, and returns markup. A public-domain street lantern of 5,394 triangles goes in and about 2,500 strokes come out, one along each edge where the surface bends, rotatable, without WebGL (the browser’s 3D graphics interface) and without a canvas element.</p>

<!-- more -->

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

<p>This post covers why no existing library does this, how <code class="language-plaintext highlighter-rouge">render()</code> sorts, how <code class="language-plaintext highlighter-rouge">featureEdges()</code> decides which edges of a shaded mesh become lines, how STEP files (the CAD exchange format) work without shipping a CAD geometry engine in the package, and what the 25 tests actually pin down.</p>

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

<p>I’ve been building a “will this part fit your bike?” planner, a tool that tells you whether a given rear shock, dropper post 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 part that makes it useful is the drawing: an exploded, service-manual-style diagram, every part numbered with a callout balloon that points at a parts list, that you can grab and rotate.</p>

<p>So I needed exploded technical diagrams, rotatable, with dimension lines, callout balloons and a title block. I went looking for a library and expected to find three.</p>

<h2 id="no-library-does-this">No library does this</h2>

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

<p>On one side is <strong>three.js</strong> and WebGL. It is excellent at shaded surfaces, lights, materials and cameras. Everything that makes a technical drawing readable is a fight there: one-pixel strokes, dashed hidden lines, a hierarchy of line weights, and text callouts that have to live in a separate HTML layer on top of the canvas. I would have spent the effort making a renderer stop doing what it is good at.</p>

<p>On the other side is <a href="https://zzz.dog/">Zdog</a>, the one pseudo-3D library with the right look. Its last npm release was v1.1.3 in January 2022; it sorts shapes by one depth value each, so shapes that pass through one another draw in the wrong order and you nudge their depth offsets by hand until they don’t; and it has no text. Dimension lines and leader text are half of an exploded diagram. A renderer that cannot do text cannot do this.</p>

<p>Nothing in between produces technical illustrations from parameters. So I wrote it.</p>

<h2 id="rotate-project-sort-paint">Rotate, project, sort, paint</h2>

<p>The pipeline is the textbook one. Rotate the 3D points about the vertical and horizontal axes; project them with perspective, so that a point’s on-screen scale is <code class="language-plaintext highlighter-rouge">f/(f−z)</code> and nearer points draw larger; sort every shape from farthest to nearest (the painter’s algorithm: paint the back first so the front covers it); then write SVG strings. The important word is <em>sort</em>, and the important property is that it happens every frame from the projected depth of every shape, never from a layer order the author supplies. This is the whole of <code class="language-plaintext highlighter-rouge">render()</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">render</span><span class="p">(</span><span class="nx">shapes</span><span class="p">:</span> <span class="nx">Shape</span><span class="p">[],</span> <span class="nx">view</span><span class="p">:</span> <span class="nx">View</span><span class="p">,</span> <span class="nx">parts</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="nx">PartDef</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">{}):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">X</span> <span class="o">=</span> <span class="nf">xform</span><span class="p">(</span><span class="nx">view</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">slots</span><span class="p">:</span> <span class="nx">Slot</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[];</span>
  <span class="kd">const</span> <span class="nx">byPart</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Map</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="p">{</span> <span class="na">svgs</span><span class="p">:</span> <span class="kr">string</span><span class="p">[];</span> <span class="nl">depths</span><span class="p">:</span> <span class="kr">number</span><span class="p">[]</span> <span class="p">}</span><span class="o">&gt;</span><span class="p">();</span>
  <span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">sh</span> <span class="k">of</span> <span class="nx">shapes</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">e</span> <span class="o">=</span> <span class="nf">emit</span><span class="p">(</span><span class="nx">sh</span><span class="p">,</span> <span class="nx">X</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">e</span><span class="p">.</span><span class="nx">svg</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">sh</span><span class="p">.</span><span class="nx">part</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* accumulate into byPart */</span> <span class="p">}</span> <span class="k">else</span> <span class="p">{</span> <span class="nx">slots</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="nx">e</span><span class="p">);</span> <span class="p">}</span>
  <span class="p">}</span>
  <span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="p">[</span><span class="nx">key</span><span class="p">,</span> <span class="nx">b</span><span class="p">]</span> <span class="k">of</span> <span class="nx">byPart</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">depth</span> <span class="o">=</span> <span class="nx">b</span><span class="p">.</span><span class="nx">depths</span><span class="p">.</span><span class="nf">reduce</span><span class="p">((</span><span class="nx">a</span><span class="p">,</span> <span class="nx">c</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">a</span> <span class="o">+</span> <span class="nx">c</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="o">/</span> <span class="nx">b</span><span class="p">.</span><span class="nx">depths</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
    <span class="nx">slots</span><span class="p">.</span><span class="nf">push</span><span class="p">({</span> <span class="na">svg</span><span class="p">:</span> <span class="s2">`&lt;g </span><span class="p">${</span><span class="nx">parts</span><span class="p">[</span><span class="nx">key</span><span class="p">]?.</span><span class="nx">attrs</span> <span class="o">??</span> <span class="dl">""</span><span class="p">}</span><span class="s2">&gt;</span><span class="p">${</span><span class="nx">b</span><span class="p">.</span><span class="nx">svgs</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="dl">""</span><span class="p">)}</span><span class="s2">&lt;/g&gt;`</span><span class="p">,</span> <span class="nx">depth</span><span class="p">,</span> <span class="na">part</span><span class="p">:</span> <span class="nx">key</span> <span class="p">});</span>
  <span class="p">}</span>
  <span class="nx">slots</span><span class="p">.</span><span class="nf">sort</span><span class="p">((</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">a</span><span class="p">.</span><span class="nx">depth</span> <span class="o">-</span> <span class="nx">b</span><span class="p">.</span><span class="nx">depth</span><span class="p">);</span> <span class="c1">// far → near</span>
  <span class="k">return</span> <span class="nx">slots</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">svg</span><span class="p">).</span><span class="nf">join</span><span class="p">(</span><span class="dl">""</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Shapes that share a <code class="language-plaintext highlighter-rouge">part</code> key are averaged into one depth and wrapped in one <code class="language-plaintext highlighter-rouge">&lt;g&gt;</code> group, so an animated part moves as a unit. A box’s faces are four-sided polygons, and a face whose corners come out clockwise on screen is facing away from the viewer, so it is skipped (<code class="language-plaintext highlighter-rouge">if (sh.cull &amp;&amp; area &lt;= 0)</code>) and the back of a box never paints over its front. A <code class="language-plaintext highlighter-rouge">disc</code> projects to the correct ellipse for the plane it sits in, which is what wheels and bearing races need. A <code class="language-plaintext highlighter-rouge">bias</code> field nudges a shape’s depth by hand for the cases every painter’s renderer has, where two shapes sit in the same plane, and the README says so: three shapes that each overlap the next in a cycle cannot be sorted, so split one of them.</p>

<p>Here is a bearing assembly, exploded. This exact SVG was rendered by the library, in Node, with no client-side JavaScript:</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="a-shaded-mesh-has-no-lines">A shaded mesh has no lines</h2>

<p>The pillow block was written by hand. The larger unlock is <code class="language-plaintext highlighter-rouge">linework/import</code>. A mesh carries no lines; its form lives in where the surface bends, so the drawing has to be recovered from the triangles. <code class="language-plaintext highlighter-rouge">featureEdges()</code> does it in three passes:</p>

<ol>
  <li><strong>Weld vertices by position.</strong> Meshes routinely split one geometric edge across two pieces with separately numbered vertices. Each vertex position is rounded to a grid one ten-thousandth of the bounding-box diagonal and mapped to a single representative, so a shared edge is seen as shared and not as two boundaries.</li>
  <li><strong>Collect the face directions at each edge.</strong> Each triangle contributes its surface normal (the direction it faces) to its three edges, keyed on the pair of welded vertex ids. Triangles with zero area are skipped; they would otherwise produce false creases.</li>
  <li><strong>Keep the edges a draftsperson would ink.</strong> The figure shows the three cases.</li>
</ol>

<p><img src="/images/blog/linework-feature-edges.svg" alt="Three panels, each a side view of triangles meeting at one edge: a boundary edge touched by only one face is kept as a line; a crease where two faces meet at more than 25 degrees is kept; an edge where two faces are nearly flat to each other is dropped, so a smooth surface produces no line." /></p>

<p>The crease threshold is the <code class="language-plaintext highlighter-rouge">angle</code> option, 25° by default. The tests say what that means concretely: a closed cube yields exactly its 12 edges and none of the diagonals across its faces; raising the threshold past 90° drops every interior edge; an open triangle is three boundary edges; vertices at the same position but numbered separately are welded. One call does the whole thing:</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><code class="language-plaintext highlighter-rouge">parseGLB</code> reads embedded buffers and node transforms; it does not support Draco compression, and it reads geometry only. Here is the lantern, 5,394 triangles of shaded mesh reduced to about 2,500 edges and rendered in Node by <code class="language-plaintext highlighter-rouge">scripts/gen-import-demo.mjs</code>:</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 why this was worth releasing. “Hand-code a 3D scene” is a small audience. “Bring the model you already have” is a larger one. Drop a <code class="language-plaintext highlighter-rouge">.glb</code> on the <a href="https://isaacrowntree.com/linework/">live demo</a> and it runs in your browser; nothing is uploaded.</p>

<h2 id="step-files-without-shipping-a-cad-engine">STEP files without shipping a CAD engine</h2>

<p>STEP and IGES files describe exact mathematical surfaces (boundary representations built from trimmed NURBS patches), not triangles, and turning those into triangles is a job for OpenCASCADE, an open-source CAD geometry engine, not for 171 lines. So linework does not embed one. <code class="language-plaintext highlighter-rouge">fromOcct()</code> maps the output of <a href="https://github.com/kovacsv/occt-import-js">occt-import-js</a> straight into the same <code class="language-plaintext highlighter-rouge">Mesh</code> type; the roughly 6 MB WebAssembly build of that engine stays an optional dependency of your app, and <code class="language-plaintext highlighter-rouge">linework</code> stays dependency-free. <code class="language-plaintext highlighter-rouge">fromBufferGeometry()</code> does the same for a three.js geometry by reading its typed arrays, without importing three.js.</p>

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

<ul>
  <li><strong>You style it.</strong> The library emits class names you choose and 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>Text is text.</strong> Callouts and dimensions are real <code class="language-plaintext highlighter-rouge">&lt;text&gt;</code>; a screen reader and a crawler 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. Both diagrams in this post came out of Node at build time. The <a href="https://isaacrowntree.com/linework/">live demo</a> runs the same <code class="language-plaintext highlighter-rouge">dist/</code> files under your pointer.</li>
</ul>

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

<p><code class="language-plaintext highlighter-rouge">linework/sketch</code> sits on top of the raw primitives so a scene reads like drafting instead of assembling arrays of numbers. Context blocks set which part, which CSS class and which depth bias 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>Annotations in paper space (fixed to the page rather than to the model) are appended after the sorted scene and never rotate, exactly like the notes on a real drawing; <code class="language-plaintext highlighter-rouge">sketch.pt(p, z)</code> projects a model point to the page so a leader line can pin a note to a part. <code class="language-plaintext highlighter-rouge">linework/orbit</code> is the drag-to-rotate code as one call: pointer capture, limits on how far the view can turn, batching of redraws to the browser’s animation frame, double-click to reset, and an idle sway that switches off when the user has asked for reduced motion. It is the only entry point that needs a browser.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm i linework          <span class="c"># 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="numbers">Numbers</h2>

<table>
  <thead>
    <tr>
      <th>Release</th>
      <th>Lines in the core</th>
      <th>Lines in the import module</th>
      <th>Tests</th>
      <th>What the release added</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>v0.1.0</td>
      <td>171</td>
      <td>none</td>
      <td>13</td>
      <td>hand-authored scenes</td>
    </tr>
    <tr>
      <td>v0.2.0</td>
      <td>171</td>
      <td>not recorded</td>
      <td>19</td>
      <td>edge extraction from meshes</td>
    </tr>
    <tr>
      <td>v0.3.0</td>
      <td>171</td>
      <td>349</td>
      <td>25</td>
      <td>STL, three.js and OpenCASCADE input</td>
    </tr>
  </tbody>
</table>

<p>The README quotes about one millisecond to rebuild a scene of a few hundred shapes. That is a figure from the demo, not a benchmark I have recorded, and I have not measured the lantern’s per-frame cost.</p>

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

<p>When a problem looks 3D there is a reflex to reach for the 3D engine and then spend a week teaching it to draw a labelled diagram. The actual need was smaller and more specific than the tool everyone reaches for, and nobody had written the small specific thing because it sits between “serious 3D” and “cute toy”. That gap is where a lot of good little libraries live. This one is <a href="https://github.com/isaacrowntree/linework">MIT on GitHub</a>: take it, theme it, drop in your own model. The bike planner it was built for is 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 171-line, zero-dependency renderer for annotated technical drawings: 3D points or a glTF, OBJ or STL mesh in, depth-sorted SVG strings out. It keeps only the edges where a mesh's surface is open or bends by more than 25 degrees — a 5,394-triangle lantern becomes about 2,500 strokes — and rotates them without 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>, the sync layer from <a href="/rampset/">Rampset</a> extracted into a library, after a week-long incident in which a finished workout was silently destroyed in production. The cause was one line: the client drained its outbox on <code class="language-plaintext highlighter-rouge">res.ok</code>, and behind Cloudflare Access an expired session redirects to a same-origin login page that <code class="language-plaintext highlighter-rouge">fetch</code> follows and reports as a 200. The write was deleted from the phone and had never reached the server. The package is an append-only op log inside a Durable Object’s SQLite plus a durable outbox on the client: 983 lines of TypeScript, 26,969 bytes of JavaScript in <code class="language-plaintext highlighter-rouge">dist/</code>, no runtime dependencies, <strong>no Postgres, no long-running container, no WebSocket</strong>. Seventeen commits and five releases went out on 16 July 2026, most of them fixing something the previous one had shipped. <strong>MIT licensed</strong>, on npm.</p>

<p>This post walks through why a Durable Object already is the ordering point that sync engines run Postgres to get, the four silent data-loss bugs the code encodes (the <code class="language-plaintext highlighter-rouge">res.ok</code> one, a cursor pointing past a reset log, a pull landing mid-workout, and one user’s writes filed under another’s journal), a fifth found while writing tests for the SQL path that had never executed, the comparison table I got wrong in my own favour, and the 71 unit and 16 workerd tests that now stand in front of all of it.</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="the-ordering-point-is-already-there">The ordering point is already there</h2>

<p>The credible offline-first engines in 2026, Zero, Electric, PowerSync, all want Postgres and a service in front of it. On an all-Cloudflare stack that is a second backend, run forever, to get one property: a single place that decides the order of one user’s writes. A Durable Object is that place by construction. It is single-threaded, it has its own SQLite, and <code class="language-plaintext highlighter-rouge">env.JOURNAL.idFromName(userKey)</code> gives every user their own. The header comment in <code class="language-plaintext highlighter-rouge">src/server/journal.ts</code> is the whole design:</p>

<blockquote>
  <p>The Durable Object is the single-threaded consistency point that orders one user’s ops; this module is the pure logic, so it’s testable without a Workers runtime. Ops are opaque: <code class="language-plaintext highlighter-rouge">{ opId, kind, payload }</code>. The log never interprets a payload, which is what keeps it a primitive rather than a database.</p>
</blockquote>

<p>The schema is three tables:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">IF</span> <span class="k">NOT</span> <span class="k">EXISTS</span> <span class="n">ops</span> <span class="p">(</span>
  <span class="n">seq</span> <span class="nb">INTEGER</span> <span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="n">AUTOINCREMENT</span><span class="p">,</span>
  <span class="n">op_id</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">UNIQUE</span><span class="p">,</span>
  <span class="n">kind</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">payload</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">ts</span> <span class="nb">INTEGER</span> <span class="k">NOT</span> <span class="k">NULL</span>
<span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">IF</span> <span class="k">NOT</span> <span class="k">EXISTS</span> <span class="n">meta</span> <span class="p">(</span><span class="n">k</span> <span class="nb">TEXT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span> <span class="n">v</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">IF</span> <span class="k">NOT</span> <span class="k">EXISTS</span> <span class="n">snapshot</span> <span class="p">(</span><span class="n">id</span> <span class="nb">INTEGER</span> <span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="k">CHECK</span> <span class="p">(</span><span class="n">id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">),</span> <span class="n">seq</span> <span class="nb">INTEGER</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span> <span class="nb">blob</span> <span class="nb">TEXT</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">op_id</code> is the idempotency key, so a client that is not sure a push landed retries it and the log ignores the duplicate. Because the DO is single-threaded the check-then-insert has no race. There is no conflict resolution on purpose: if writes are events (“this workout happened”) rather than edits, two devices appending do not conflict, they interleave. HTTP rather than WebSocket, because gyms are Faraday cages and a socket that cannot connect is not a feature. The server is a class you extend and a route you write; what you do not forward, a client cannot reach:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// examples/notes/worker.js</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="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="dl">"</span><span class="s2">demo</span><span class="dl">"</span><span class="p">));</span>
<span class="k">if </span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">method</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</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="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><code class="language-plaintext highlighter-rouge">reset()</code> exists on the DO. Nothing routes to it. That is the access model, and it is why 0.3.0 replaced the DO’s internal <code class="language-plaintext highlighter-rouge">fetch</code> router with native RPC methods: the router was a layer I did not write, sitting between the Worker and the log, special-casing <code class="language-plaintext highlighter-rouge">DELETE</code>.</p>

<h2 id="bug-one-a-successful-status-code-is-not-evidence">Bug one: a successful status code is not evidence</h2>

<p>The first version of the client in Rampset did what every example does: <code class="language-plaintext highlighter-rouge">POST</code> the outbox, and on <code class="language-plaintext highlighter-rouge">res.ok</code> delete the queued rows. Cloudflare Access sits in front of the Worker. When the Access session expired, the request was redirected to the login page on the same origin, <code class="language-plaintext highlighter-rouge">fetch</code> followed the redirect, and the response was a 200 with an HTML body. The outbox was drained against it. The fix in <code class="language-plaintext highlighter-rouge">src/client/transport.ts</code> is a type guard on the body, and the comment above it is the incident report:</p>

<p><img src="/images/blog/durable-sync-redirect-200.svg" alt="Before the fix: the phone posts its queued writes, the expired Cloudflare Access session answers with a redirect to the login page, fetch follows it and reports a 200 with an HTML body, the client treats that as success and deletes the queue, and the workout is gone. After the fix: the same redirect arrives, the client checks the body is the server's own reply shape before trusting it, keeps the queue, and retries after sign-in." /></p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/** Did this response really come from the journal?
 *
 * Behind an auth proxy (Cloudflare Access, and others), an expired session
 * redirects to a same-origin login page — which fetch FOLLOWS, and reports as
 * a 200. `res.ok` is therefore not evidence of anything. The outbox may hold
 * the only copy of a write, so it may only be drained against a reply the
 * journal demonstrably wrote. */</span>
<span class="kd">function</span> <span class="nf">isJournalReply</span><span class="o">&lt;</span><span class="nx">T</span> <span class="kd">extends</span> <span class="p">{</span> <span class="nx">seq</span><span class="p">?:</span> <span class="nx">unknown</span> <span class="p">}</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">body</span><span class="p">:</span> <span class="nx">T</span> <span class="o">|</span> <span class="kc">null</span><span class="p">):</span> <span class="nx">body</span> <span class="k">is</span> <span class="nx">T</span> <span class="o">&amp;</span> <span class="p">{</span> <span class="na">seq</span><span class="p">:</span> <span class="kr">number</span> <span class="p">}</span> <span class="p">{</span>
  <span class="k">return</span> <span class="k">typeof</span> <span class="nx">body</span><span class="p">?.</span><span class="nx">seq</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">number</span><span class="dl">"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The client also sends <code class="language-plaintext highlighter-rouge">x-requested-with: XMLHttpRequest</code>, which makes a well-behaved proxy answer an expired session with a 401 instead of a login page. That is belt and braces; the guard is what protects the outbox.</p>

<h2 id="bug-two-a-cursor-only-means-something-against-the-log-that-issued-it">Bug two: a cursor only means something against the log that issued it</h2>

<p>Each client stores the highest <code class="language-plaintext highlighter-rouge">seq</code> it has applied and pulls <code class="language-plaintext highlighter-rouge">seq &gt; cursor</code>. During the incident I reset the journal. Every client was then holding a cursor from a log that no longer existed, usually pointing <em>past</em> the rebuilt one, so <code class="language-plaintext highlighter-rouge">seq &gt; cursor</code> matched nothing, and every client silently never synced again. Now the <code class="language-plaintext highlighter-rouge">meta</code> table carries an <code class="language-plaintext highlighter-rouge">epoch</code>, a <code class="language-plaintext highlighter-rouge">crypto.randomUUID()</code> stamped on first use and replaced by <code class="language-plaintext highlighter-rouge">reset()</code>, and every pull reply carries it:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">epoch</span> <span class="o">=</span> <span class="nx">body</span><span class="p">.</span><span class="nx">epoch</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="nx">epoch</span> <span class="o">&amp;&amp;</span> <span class="nx">epoch</span> <span class="o">!==</span> <span class="nx">current</span><span class="p">.</span><span class="nx">epoch</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// The replay a reset forces is the most expensive pull there is, so</span>
  <span class="c1">// it's the one that most wants a fold.</span>
  <span class="nx">body</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetchPull</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">!!</span><span class="nx">snapshot</span><span class="p">);</span>
</code></pre></div></div>

<p><img src="/images/blog/durable-sync-cursor-epoch.svg" alt="Left: the client remembers the last sequence number it applied, 120; after the journal is rebuilt from scratch the new log only reaches 8, so a pull for everything after 120 matches nothing and the client silently never syncs again. Right: the log now carries a random epoch id; when a pull reply names a different epoch from the one the client stored, the cursor resets to zero and the log is replayed, which is safe because applying an op twice is required to be harmless." /></p>

<p><code class="language-plaintext highlighter-rouge">apply</code> is required to be idempotent, so a replay from zero is cheap and safe. There is a smaller bug hiding next to this one: when a reply omits the epoch the client must keep the one it knows, not write <code class="language-plaintext highlighter-rouge">undefined</code>, or the next reply that has one looks like a new generation and triggers a needless full replay. Rampset guarded that. The extraction regressed it, and the second commit of the day put it back.</p>

<h2 id="bug-three-pulling-is-not-always-safe">Bug three: pulling is not always safe</h2>

<p>Pushing never disturbs the device that pushes. Pulling applies someone else’s op to local state, and in Rampset a remote op landing mid-workout rewrote the working weight the finish logic reads back, and turned a logged 5×5 into a 3×5. The engine therefore has two gates and they are asymmetric:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nf">run</span><span class="p">():</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">number</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">pushed</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">transport</span><span class="p">.</span><span class="nf">push</span><span class="p">();</span>
  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="p">(</span><span class="k">await</span> <span class="nf">canPull</span><span class="p">()))</span> <span class="p">{</span>
    <span class="c1">// A clean push is as synced as we can honestly claim right now.</span>
    <span class="nf">record</span><span class="p">(</span><span class="nx">pushed</span><span class="p">.</span><span class="nx">ok</span> <span class="p">?</span> <span class="p">{</span> <span class="na">lastOkAt</span><span class="p">:</span> <span class="nf">now</span><span class="p">()</span> <span class="p">}</span> <span class="p">:</span> <span class="p">{</span> <span class="p">...</span><span class="nx">snapshot</span><span class="p">,</span> <span class="na">lastError</span><span class="p">:</span> <span class="nx">UNREACHABLE</span> <span class="p">});</span>
    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="kd">const</span> <span class="nx">pulled</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">transport</span><span class="p">.</span><span class="nf">pull</span><span class="p">();</span>
</code></pre></div></div>

<p>Returning <code class="language-plaintext highlighter-rouge">false</code> from <code class="language-plaintext highlighter-rouge">canPull()</code> pushes and tries again later. Eventual consistency makes the wait free.</p>

<h2 id="bug-four-the-gate-has-to-be-the-only-door">Bug four: the gate has to be the only door</h2>

<p>The journal is addressed by the server-side identity; the ops carry whatever lifter the client had selected. With <code class="language-plaintext highlighter-rouge">canWrite()</code> on the engine but <code class="language-plaintext highlighter-rouge">createTransport</code> exported, a caller could reach past the gate and push directly, which is exactly how Rampset posted one user’s workouts to another’s journal, got a valid ack, and drained the outbox against it. In an append-only log that is permanent. The fix is not a check, it is a missing export. The top of <code class="language-plaintext highlighter-rouge">src/client/index.ts</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// createTransport is deliberately NOT exported. Reaching past createSync to</span>
<span class="c1">// the transport bypasses canWrite() — which is exactly how the app this came</span>
<span class="c1">// from posted one user's workouts to another's journal and drained the outbox</span>
<span class="c1">// against the ack. The gate is the only door.</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">sync.now({ force })</code> skips the ten-second throttle and never the gate.</p>

<h2 id="bug-five-arrived-while-writing-tests-for-bug-one">Bug five arrived while writing tests for bug one</h2>

<p>0.1.0 shipped with 33 tests, every one of them against <code class="language-plaintext highlighter-rouge">MemoryOpStore</code>. <code class="language-plaintext highlighter-rouge">SqlOpStore</code>, the class every production journal actually runs, had no tests, so no constraint or upsert in it had ever been executed by the suite. Writing them (against real SQLite through <code class="language-plaintext highlighter-rouge">node:sqlite</code>) surfaced a bug in the reply format. The journal refuses an op with a blank <code class="language-plaintext highlighter-rouge">opId</code> or <code class="language-plaintext highlighter-rouge">kind</code> and reported that in <code class="language-plaintext highlighter-rouge">accepted</code>, but the client drained the whole outbox against any well-formed reply and never looked. A count cannot carry the distinction: <code class="language-plaintext highlighter-rouge">accepted: 1</code> of two ops means one stored and one refused, or one stored and one duplicate, and those need opposite handling. 0.2.0 changed <code class="language-plaintext highlighter-rouge">push</code> to return <code class="language-plaintext highlighter-rouge">stored: string[]</code>, the ids the log now holds, and the client drains exactly those:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">stored</span> <span class="o">=</span> <span class="nb">Array</span><span class="p">.</span><span class="nf">isArray</span><span class="p">(</span><span class="nx">reply</span><span class="p">.</span><span class="nx">stored</span><span class="p">)</span> <span class="p">?</span> <span class="k">new</span> <span class="nc">Set</span><span class="p">(</span><span class="nx">reply</span><span class="p">.</span><span class="nx">stored</span> <span class="kd">as </span><span class="nx">unknown</span><span class="p">[])</span> <span class="p">:</span> <span class="kc">null</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">drain</span> <span class="o">=</span> <span class="nx">stored</span> <span class="p">?</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">filter</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">stored</span><span class="p">.</span><span class="nf">has</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">opId</span><span class="p">))</span> <span class="p">:</span> <span class="nx">rows</span><span class="p">;</span>
<span class="k">await</span> <span class="nx">outbox</span><span class="p">.</span><span class="nf">remove</span><span class="p">(</span><span class="nx">drain</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">id</span><span class="p">));</span>
</code></pre></div></div>

<p>A Worker older than the client sends no <code class="language-plaintext highlighter-rouge">stored</code>, and the client keeps the old behaviour rather than stall a rollout where the PWA leads the Worker. The same bug as trusting <code class="language-plaintext highlighter-rouge">res.ok</code>, one layer in.</p>

<h2 id="proving-the-sql-runs-where-it-ships">Proving the SQL runs where it ships</h2>

<p>The unit suite proves the logic and that the SQL parses. It cannot prove the SQL behaves the same on the DO’s own SQLite, which is the one place a divergence would only ever surface at runtime, in production. So there is an opt-in integration suite that boots workerd through <code class="language-plaintext highlighter-rouge">@cloudflare/vitest-pool-workers</code> and drives a real <code class="language-plaintext highlighter-rouge">SyncJournal</code>: push and the <code class="language-plaintext highlighter-rouge">stored[]</code> ack, pull, snapshot upsert, reset dropping the snapshot, the stale-epoch refusal.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx vitest run
 Test Files  4 passed (4)
      Tests  71 passed (71)
   Duration  174ms

npx vitest run --config vitest.integration.config.ts
 Test Files  1 passed (1)
      Tests  16 passed (16)
   Duration  1.41s
</code></pre></div></div>

<p>It is <code class="language-plaintext highlighter-rouge">npm run test:integration</code>, not <code class="language-plaintext highlighter-rouge">npm test</code>, and its CI workflow is <code class="language-plaintext highlighter-rouge">workflow_dispatch</code> only, because it is an order of magnitude slower and never needs to block a publish. The integration worker imports <code class="language-plaintext highlighter-rouge">src/</code>, not <code class="language-plaintext highlighter-rouge">dist/</code>, so a regression is caught before a publish.</p>

<h2 id="what-i-got-wrong-about-the-competition">What I got wrong about the competition</h2>

<p>0.1.2 has no code changes. The docs site carries a <a href="https://isaacrowntree.com/durable-sync/vs.html">comparison</a> against Zero, Electric, PowerSync and Cloudflare’s partysync, and when I fact-checked it against their primary docs three of the four rival rows were wrong, every error in my favour. partysync is delta sync with an IndexedDB read cache, so the gap is a write queue and nothing else; Zero rejects writes while disconnected, which is an explicit non-goal rather than a shortcoming; PowerSync’s “conflict resolution: yes” had been overstated. The commit is titled “Correct the rival claims — every error flattered us”. The table now says, in its own caveat, that it was written by someone with an obvious interest.</p>

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

<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>The outbox must be IndexedDB, not memory: until a push is acknowledged it may be the only copy of the write in existence, and iOS kills backgrounded PWAs. <code class="language-plaintext highlighter-rouge">start()</code> syncs on <code class="language-plaintext highlighter-rouge">visibilitychange</code>, <code class="language-plaintext highlighter-rouge">focus</code>, <code class="language-plaintext highlighter-rouge">online</code> and <code class="language-plaintext highlighter-rouge">pageshow</code> (a tab restored from the bfcache does not fire <code class="language-plaintext highlighter-rouge">visibilitychange</code>), with a <code class="language-plaintext highlighter-rouge">minIntervalMs</code> of 10 s to collapse the burst a single foregrounding produces, and a 5-minute poll as a safety net that iOS will freeze anyway. There is a <a href="https://github.com/isaacrowntree/durable-sync/tree/main/examples/notes">runnable example</a> in the repo, a Worker plus a browser client with a real IndexedDB outbox, and a <a href="https://isaacrowntree.com/durable-sync/">live demo</a> whose Offline button flips <code class="language-plaintext highlighter-rouge">window.fetch</code> so you can watch ops queue with no sequence number and then drain.</p>

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

<p>A pull returns everything after the cursor in one response, fine for thousands of ops and not for millions. There is no live push; the other device converges on its next foreground. There is no auth: the DO key is your isolation boundary. One <code class="language-plaintext highlighter-rouge">createSync</code> per identity, memoised, because the throttle lives in the closure. And nothing runs while the app is closed, because Safari has no Background Sync and pretending otherwise would be a lie. If two people need to edit the same record and have it merge, you want a CRDT, Yjs or Automerge. If you already run Postgres, Zero, Electric and PowerSync are better engineered for the general case. The slice where durable-sync is the right answer is narrow: all-Cloudflare, writes that are events, and an app that has to keep working on a train.</p>

<h2 id="what-the-extraction-taught-me">What the extraction taught me</h2>

<p>The op log is a hundred lines and the outbox is an interface; anyone could write those in an afternoon. Every non-obvious line in the package is a bug that shipped, and each of them failed silently with <code class="language-plaintext highlighter-rouge">lastOkAt</code> set and the status row green. The one that cost a workout was a redirect <code class="language-plaintext highlighter-rouge">fetch</code> followed on my behalf. The one that cost every client’s sync was a <code class="language-plaintext highlighter-rouge">reset()</code> I ran myself. The fifth was found only because the class that runs in production finally had a test, which is the sentence I would put at the top of the README if I were starting again. 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 zero-dependency TypeScript library that gives Cloudflare Durable Objects an offline-first sync loop: an append-only op log in the DO's SQLite, a durable outbox on the client. Extracted from Rampset after a finished workout was silently destroyed in production. 27 KB of JavaScript, 71 unit tests, 16 more against workerd, MIT, 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 portfolio manager for Interactive Brokers (IBKR), built on <a href="https://github.com/isaacrowntree/bezant">bezant</a>. The reason it exists is one number: a single holding had grown to <strong>41.6% of my whole account</strong>, and trimming it by hand meant placing real orders late at night, in US dollars, on a cash account (one that cannot borrow) whose base currency is Australian dollars. Sovereign is nine TypeScript programs, each with one job, that hold a target mix of holdings, notice when the real mix has drifted from it, work out the smallest trades that bring it back, and place them through bezant. The same inputs always produce the same trades, and 509 test cases across 40 files pin down the ways that can go wrong.</p>

<!-- more -->

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

<p>This post covers the executor’s rule that one small probe order must fill before it trusts the connection with a batch, and why that probe is removed from the queue by object reference rather than by position; the three caps in absolute dollars that sit outside the order-sizing maths; and the three bugs found by the pre-launch risk register, a written review of every way the executor could lose money: net asset value (the account’s total worth, “NAV” from here on) read in the wrong currency and counted twice, a confirmation timeout that could place the same trade twice, and a tax-lot matcher that paired sales with purchases by record and ignored how many shares each held. All three are fixed in v0.1.0; the register that found them ships in <code class="language-plaintext highlighter-rouge">docs/execution-risk-register.md</code>.</p>

<h2 id="nine-agents-and-not-one-language-model-call">Nine agents and not one language-model call</h2>

<p>Every agent is a plain process: <code class="language-plaintext highlighter-rouge">node dist/agents/&lt;name&gt;.js --once</code>. There are no calls to a large language model anywhere in the loop. A trade decision is a function of its inputs (positions, prices, the target portfolio, the risk state) and has to be reproducible from the state file, so it cannot come from a model that gives a different answer each time it is asked.</p>

<table>
  <thead>
    <tr>
      <th>Agent</th>
      <th>Job</th>
      <th>Default cadence</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Managing Partner</strong></td>
      <td>Orchestrates the fund, records NAV and positions</td>
      <td>every 4 hours</td>
    </tr>
    <tr>
      <td><strong>Portfolio Strategist</strong></td>
      <td>Chooses target weights (Hierarchical Risk Parity or Black-Litterman), detects drift, sizes the rebalancing orders</td>
      <td>every 4 hours</td>
    </tr>
    <tr>
      <td><strong>Quant Analyst</strong></td>
      <td>Detects the market regime, runs factor regressions</td>
      <td>every 4 hours</td>
    </tr>
    <tr>
      <td><strong>Risk Manager</strong></td>
      <td>Value at risk and conditional value at risk, drawdown control, volatility targeting</td>
      <td>every 4 hours</td>
    </tr>
    <tr>
      <td><strong>Execution Bot</strong></td>
      <td>Places queued orders through bezant, only inside the trading window and under the caps</td>
      <td>every 4 hours</td>
    </tr>
    <tr>
      <td><strong>Tax Optimizer</strong></td>
      <td>Tracks purchase lots first-in-first-out, harvests tax losses, tracks wash sales</td>
      <td>daily</td>
    </tr>
    <tr>
      <td><strong>Hedger</strong></td>
      <td>Options overlay (covered calls, protective puts)</td>
      <td>daily</td>
    </tr>
    <tr>
      <td><strong>Research Scout</strong></td>
      <td>Price monitoring, alerts</td>
      <td>daily</td>
    </tr>
    <tr>
      <td><strong>Observer</strong></td>
      <td>Listens to bezant’s live stream of fills and order events</td>
      <td>continuous</td>
    </tr>
  </tbody>
</table>

<p>Underneath is a set of ways to turn price history into target weights: Hierarchical Risk Parity, Black-Litterman, risk parity, and Ledoit-Wolf shrinkage for estimating the covariance matrix, plus a market-regime overlay and volatility targeting, each with its own test file under <code class="language-plaintext highlighter-rouge">src/portfolio/</code>, <code class="language-plaintext highlighter-rouge">src/quant/</code> and <code class="language-plaintext highlighter-rouge">src/risk/</code>.</p>

<h2 id="prove-one-fill-before-trusting-the-connection">Prove one fill before trusting the connection</h2>

<p>The executor never sends a batch of orders on a session until one live order has been confirmed as filled. <code class="language-plaintext highlighter-rouge">planExecution</code> in <code class="language-plaintext highlighter-rouge">src/execution/staging.ts</code> puts it in <code class="language-plaintext highlighter-rouge">validate</code> mode: one order, the smallest-value sell (or the smallest order at all if there are no sells), and nothing else until IBKR’s execution records confirm the fill.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">validated</span> <span class="o">&amp;&amp;</span> <span class="nx">pending</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">pool</span> <span class="o">=</span> <span class="nx">sells</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="p">?</span> <span class="nx">sells</span> <span class="p">:</span> <span class="nx">buys</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">probe</span> <span class="o">=</span> <span class="p">[...</span><span class="nx">pool</span><span class="p">].</span><span class="nf">sort</span><span class="p">((</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">a</span><span class="p">.</span><span class="nx">estimatedValue</span> <span class="o">-</span> <span class="nx">b</span><span class="p">.</span><span class="nx">estimatedValue</span><span class="p">)[</span><span class="mi">0</span><span class="p">];</span>
  <span class="k">return</span> <span class="p">{</span>
    <span class="na">mode</span><span class="p">:</span> <span class="dl">'</span><span class="s1">validate</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">orders</span><span class="p">:</span> <span class="p">[</span><span class="nx">probe</span><span class="p">],</span>
    <span class="c1">// Exclude the probe by identity, NOT by position: the smallest-value</span>
    <span class="c1">// probe is frequently not queue index 0 (the strategist emits sells</span>
    <span class="c1">// loss-first, not cheapest-first), so `slice(1)` would drop the real</span>
    <span class="c1">// index-0 order AND re-queue the probe that just executed — a</span>
    <span class="c1">// duplicate order on a live account.</span>
    <span class="na">deferred</span><span class="p">:</span> <span class="nx">pending</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="nx">o</span> <span class="o">=&gt;</span> <span class="nx">o</span> <span class="o">!==</span> <span class="nx">probe</span><span class="p">),</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That comment is there because the first version used <code class="language-plaintext highlighter-rouge">slice(1)</code>, which drops whatever is first in the queue. The figure shows why that is the wrong thing to drop.</p>

<p><img src="/images/blog/sovereign-probe-by-identity.svg" alt="Two panels showing a queue of three sell orders where the cheapest, chosen as the probe, sits at position 2 not position 0: dropping the first element by position throws away the real first order and re-queues the probe for a second execution, while filtering the probe out by object identity leaves exactly the two other orders deferred." /></p>

<p>A duplicate order on a live account is the whole class of failure this mode exists to prevent.</p>

<h2 id="caps-that-do-not-trust-the-maths">Caps that do not trust the maths</h2>

<p>Order sizes are worked out as a percentage of NAV. If the NAV is garbage, the percentage is garbage, so there is a second gate in absolute dollars that does not know about the sizing at all. <code class="language-plaintext highlighter-rouge">orderCapViolation</code> in <code class="language-plaintext highlighter-rouge">src/risk/data-sanity.ts</code> checks every order against three caps set by environment variables, and the executor halts the run if any is breached:</p>

<table>
  <thead>
    <tr>
      <th>Cap</th>
      <th>What it limits</th>
      <th>Default</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MAX_ORDER_NOTIONAL_USD</code></td>
      <td>the value of any one order</td>
      <td>15,000 US dollars</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MAX_ORDER_PCT_NAV</code></td>
      <td>any one order as a share of NAV</td>
      <td>50%</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MAX_RUN_NOTIONAL_USD</code></td>
      <td>the total value of all orders in one run</td>
      <td>60,000 US dollars</td>
    </tr>
  </tbody>
</table>

<p>In front of that, <code class="language-plaintext highlighter-rouge">navSanityViolation</code> and <code class="language-plaintext highlighter-rouge">priceSanityViolations</code> refuse to size anything when the numbers look impossible: a NAV of zero or less, a NAV below a floor, a NAV that has moved more than a configured percentage since the last cycle, or a price that has jumped more than a configured percentage. The strategist logs, alerts and gives up rather than generating orders against a NAV that reads zero or a price that reads a hundred times too low. On top of that, loss thresholds first reduce exposure and then stop trading altogether, and everything stateful lives in <code class="language-plaintext highlighter-rouge">STATE_DIR</code>, outside the checkout, so a deploy cannot overwrite the ledger.</p>

<h2 id="the-nav-was-in-the-wrong-currency">The NAV was in the wrong currency</h2>

<p>The risk register is a review of the executor against the live account before unpausing it, and its first finding was a units error. The account is held with Interactive Brokers Australia, with Australian dollars as its base currency. The API’s <code class="language-plaintext highlighter-rouge">/summary</code> endpoint reports <code class="language-plaintext highlighter-rouge">totalcashvalue</code> and <code class="language-plaintext highlighter-rouge">netliquidation</code> in the base currency, so the cash check was comparing US-dollar purchase costs against Australian-dollar cash, and the strategist was sizing US shares as a fraction of a NAV stated in Australian dollars.</p>

<p>I expected <code class="language-plaintext highlighter-rouge">/summary</code> to be the account’s cash. What the live <code class="language-plaintext highlighter-rouge">/ledger</code> endpoint showed was a separate balance for each currency, which IBKR does not convert: a US-dollar cash balance of $0, US-dollar settled cash of $0, and most of the NAV held in US stock. A cash account cannot borrow US dollars, so US-dollar purchases have to be funded by the proceeds of the US-dollar sales that run first, which the sells-before-buys ordering already guarantees. The fix is <code class="language-plaintext highlighter-rouge">gateway.getUsdBalances()</code>, which reads <code class="language-plaintext highlighter-rouge">/ledger</code> and returns US-dollar cash and US-dollar NAV, skipping the <code class="language-plaintext highlighter-rouge">BASE</code> row (IBKR’s total in the base currency rather than a real currency). That last clause matters: with <code class="language-plaintext highlighter-rouge">BASE</code> included, the NAV came out roughly doubled.</p>

<p>Re-running the strategist on the corrected numbers, verified against the live account (US-dollar cash $0.00, US-dollar NAV $29,155.64), showed the old quantities were oversized by the exchange rate:</p>

<table>
  <thead>
    <tr>
      <th>Order</th>
      <th>Sized on the mixed-currency NAV</th>
      <th>Sized on the US-dollar NAV</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>AVGO</td>
      <td>9 shares</td>
      <td>6 shares</td>
    </tr>
    <tr>
      <td>TLT</td>
      <td>39 shares</td>
      <td>27 shares</td>
    </tr>
  </tbody>
</table>

<p>The weights now sum to about 100%, and the concentrated holding’s true weight was 41.6%, not the 29% the mixed-currency maths had reported.</p>

<h2 id="a-timeout-is-not-a-cancel">A timeout is not a cancel</h2>

<p>Second finding. When the wait for a fill confirmation timed out, the executor dropped the order rather than requeue it, on the stated assumption that the strategist would rebuild the queue from live positions. The strategist reads positions only; it knows nothing about orders still open at the broker. If the timed-out order was still working at IBKR, the drift persisted, the strategist’s next run generated the same order again, and the executor submitted it a second time.</p>

<p>The fix was already one route away: bezant exposes <code class="language-plaintext highlighter-rouge">DELETE /accounts/{id}/orders/{orderId}</code>, and it just was not wired up. <code class="language-plaintext highlighter-rouge">gateway.cancelOrder()</code> now runs, on a best-effort basis, after a confirmation timeout, an error on the confirmation stream, or a partial fill that timed out, before the run halts. A failed cancel leaves the risk that was already there; it never throws.</p>

<h2 id="matching-sales-by-record-is-not-matching-them-by-share">Matching sales by record is not matching them by share</h2>

<p>Third finding. The original tax-lot matcher paired each sale with exactly one earliest unmatched purchase record and ignored quantities. A sale that spanned two purchase lots priced the whole quantity off one lot, which could flip the sign of the realised profit or loss. That matters because only a sale at a loss opens a wash-sale window (the rule that disallows a tax loss if the same stock is bought back within a set number of days), so a loss that was wrongly reported as a gain let the strategist buy the stock back inside 31 days. And a sale smaller than its lot marked the whole lot consumed, so the next sale of the same symbol found no cost basis at all.</p>

<p><code class="language-plaintext highlighter-rouge">matchSellFifo</code> in <code class="language-plaintext highlighter-rouge">src/tax/fifo.ts</code> now replays the history, reduces each purchase lot by the shares that earlier sales already consumed, and consumes the current sale oldest lot first, producing a weighted cost basis, a <code class="language-plaintext highlighter-rouge">longTermQty</code> (the shares held over a year, which qualify for the Australian capital-gains discount), and a <code class="language-plaintext highlighter-rouge">matchedLots[]</code> array on the trade record. Eight unit tests cover it, including the multi-lot sign flip.</p>

<h2 id="ibkrs-execution-records-win">IBKR’s execution records win</h2>

<p>Fills reach the ledger by two paths: the executor records what the WebSocket stream confirmed, and <code class="language-plaintext highlighter-rouge">src/execution/reconcile.ts</code> backfills from IBKR’s execution history. The reconciler records each execution id once however many times it sees it, with a fallback key for records written before execution ids were captured, so a fill that arrives by both paths is recorded once. If the stream said an order did not fill and IBKR’s executions say it did, the executions win and the ledger is corrected with a logged <code class="language-plaintext highlighter-rouge">RECONCILED</code> line.</p>

<h2 id="run-it-under-any-scheduler">Run it under any scheduler</h2>

<p><code class="language-plaintext highlighter-rouge">npm start</code> runs the built-in scheduler and a status server, with each agent on the cadence above (<code class="language-plaintext highlighter-rouge">SCHED_*_SEC</code> to tune). Set <code class="language-plaintext highlighter-rouge">ENABLE_SCHEDULER=false</code> and any scheduler (cron, systemd timers, something else) can drive the same <code class="language-plaintext highlighter-rouge">--once</code> scripts; <code class="language-plaintext highlighter-rouge">deploy/</code> has systemd unit and timer files. The core never imports the scheduler.</p>

<h2 id="your-real-portfolio-stays-out-of-git">Your real portfolio stays out of git</h2>

<p>The public repo ships <code class="language-plaintext highlighter-rouge">src/portfolios/sample.ts</code>, a template of eight exchange-traded funds, and runs against a paper account out of the box:</p>

<table>
  <thead>
    <tr>
      <th>Fund</th>
      <th>QQQ</th>
      <th>XLI</th>
      <th>XLV</th>
      <th>XLF</th>
      <th>VIG</th>
      <th>VDC</th>
      <th>TLT</th>
      <th>GLD</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Target weight</td>
      <td>20%</td>
      <td>10%</td>
      <td>10%</td>
      <td>10%</td>
      <td>15%</td>
      <td>10%</td>
      <td>15%</td>
      <td>10%</td>
    </tr>
  </tbody>
</table>

<p>Weights must sum to 100 and <code class="language-plaintext highlighter-rouge">validateTargets()</code> enforces it. A real allocation goes in <code class="language-plaintext highlighter-rouge">src/portfolios/local.ts</code>, which is gitignored and takes precedence automatically. Caps, thresholds, cadences and the choice of optimiser are all environment variables; nothing about a real position is in source.</p>

<p>The backtest engine runs on a Yahoo Finance daily price dataset that is also gitignored: <code class="language-plaintext highlighter-rouge">npm run fetch-data</code> writes it, and the backtest suites skip 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.0</strong>: runs end-to-end against IBKR paper accounts; the set of agents will evolve.</li>
  <li><strong>Dual-licensed under Apache-2.0 or MIT.</strong></li>
  <li><strong>Requires a running <a href="https://github.com/isaacrowntree/bezant">bezant</a> gateway</strong>, on <code class="language-plaintext highlighter-rouge">http://localhost:8080</code> by default.</li>
  <li><strong>Not affiliated with Interactive Brokers.</strong> This is not financial advice. It places real trades: <strong>start on a paper account</strong>, and read every guardrail before setting <code class="language-plaintext highlighter-rouge">TRADING_MODE=live</code>.</li>
</ul>

<p>The lesson from the register is that the dangerous bugs were not in the optimiser. They were units, identity and ordering: Australian dollars where US dollars were assumed, a queue position where an object reference was needed, a record where a share count was needed. Each was caught by reading the live account’s actual responses against what the code assumed, and each now has a test. If bezant made IBKR programmable, Sovereign is the part that makes a portfolio follow a written policy, with brakes that are independent of the policy. Contributions welcome, especially on the optimisers 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[One holding had grown to 41.6% of my Interactive Brokers account. Sovereign is the open-source portfolio manager I built to fix that on rules instead of instinct: nine single-purpose TypeScript agents on top of bezant, an executor that proves one fill before it sends a batch, hard caps in dollars, and the currency bug the risk register caught before the first live order. 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 strength-training app that works offline first, built as a progressive web app (a website you install like an app), and it replaced the app I had logged my lifting in for years. A sign-in bug locked me out of that app; support’s resolution, after a morning of screenshots, was to cancel my grandfathered subscription, and the only way back was a new plan at a much higher price. My history exported to CSV, 1,270 workouts, and that file became Rampset’s first import. It runs three training styles on one engine, works in a gym with zero signal, syncs between devices through a Durable Object per user (Cloudflare’s single-instance server object with its own SQLite database), and saves a snapshot of every workout to R2, Cloudflare’s file storage. <strong>MIT licensed</strong>, built to self-host on your own Cloudflare account.</p>

<p>This post covers the engine (linear progression; the stall protocol that steps a lift from five sets of five down to three sets of five and then one set of five; weight reductions scaled to how long you were away; Madcow’s ramp fractions), the offline mechanics (a three-second network timeout in the service worker, a rest timer based on a saved start time rather than a countdown, a bell synthesised from two sine tones), and the production incident on 14 and 15 July in which a finished workout was erased, which produced four fixes that later became the <a href="/durable-sync/">durable-sync</a> library.</p>

<!-- more -->

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

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

<p>The constraint that shaped the architecture was not me. My wife trains too, and her program looks nothing like mine. So the <code class="language-plaintext highlighter-rouge">Program</code> row carries a mode, and <code class="language-plaintext highlighter-rouge">finishWorkout</code> branches on it:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">type</span> <span class="nx">ProgramMode</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">progression</span><span class="dl">"</span> <span class="o">|</span> <span class="dl">"</span><span class="s2">routine</span><span class="dl">"</span> <span class="o">|</span> <span class="dl">"</span><span class="s2">madcow</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<ul>
  <li>
    <p><strong>Program mode</strong> is StrongLifts-style training: five sets of five, alternating two workouts (A and B), with the family of variants (Plus, Lite, Mini, Ultra, Ultra Max). The engine in <code class="language-plaintext highlighter-rouge">src/lib/progression.ts</code> is a pure function. A successful workout adds <code class="language-plaintext highlighter-rouge">incrementKg</code> to the lift: 2.5 kg on the main lifts, 5 kg on the deadlift, nothing on the assistance lifts. Three failures in a row subtract <code class="language-plaintext highlighter-rouge">deloadPct</code>, 10%, rounded to the nearest 2.5 kg. The stall protocol lives on the program slot as <code class="language-plaintext highlighter-rouge">deloadCount</code>: after the first deload the lift stays at five sets of five, after the second it drops to three sets of five, after the third to one set of five. Warm-up sets ramp up to the working weight like this:</p>

    <table>
      <thead>
        <tr>
          <th>Warm-up set</th>
          <th>Fraction of the working weight</th>
          <th>Reps</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>1</td>
          <td>one half</td>
          <td>5</td>
        </tr>
        <tr>
          <td>2</td>
          <td>two thirds</td>
          <td>5</td>
        </tr>
        <tr>
          <td>3</td>
          <td>five sixths</td>
          <td>3</td>
        </tr>
        <tr>
          <td>4</td>
          <td>eleven twelfths</td>
          <td>2</td>
        </tr>
      </tbody>
    </table>

    <p>Plate math works out the plates for one side of the bar, largest first, from the 20, 15, 10, 5, 2.5 and 1.25 kg plates, and returns <code class="language-plaintext highlighter-rouge">null</code> rather than lie when a weight cannot be loaded exactly.</p>
  </li>
  <li>
    <p><strong>Madcow mode</strong> is five sets of five ramped up over the week. <code class="language-plaintext highlighter-rouge">src/lib/madcow.ts</code> encodes the three days directly:</p>

    <table>
      <thead>
        <tr>
          <th>Day</th>
          <th>What it does</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>Heavy</td>
          <td>four sets climbing through 50%, 62.5%, 75% and 87.5% of the top weight, then a top set of five</td>
        </tr>
        <tr>
          <td>Light</td>
          <td>the same ramp, stopping at 75%</td>
        </tr>
        <tr>
          <td>Intensity</td>
          <td>climbs to a personal-record set of three reps at the top weight plus one increment, then a back-off set of eight at 75%</td>
        </tr>
      </tbody>
    </table>

    <p>If the personal-record set succeeds, next week’s top weight moves up.</p>
  </li>
  <li>
    <p><strong>Routine mode</strong> uses the layout of the Strong app: exercise cards with a row per set, everything prefilled from the last session by <code class="language-plaintext highlighter-rouge">src/lib/prefill.ts</code>, a rest time per exercise, timed sets and bodyweight sets. You prescribe; the app remembers.</p>
  </li>
</ul>

<p>Coming back after time off is handled by <code class="language-plaintext highlighter-rouge">src/lib/deload.ts</code>, and it is the whole file:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">deloadPctForLayoff</span><span class="p">(</span><span class="nx">daysAway</span><span class="p">:</span> <span class="kr">number</span><span class="p">):</span> <span class="kr">number</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">daysAway</span> <span class="o">&gt;=</span> <span class="mi">56</span><span class="p">)</span> <span class="k">return</span> <span class="mf">0.3</span><span class="p">;</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">daysAway</span> <span class="o">&gt;=</span> <span class="mi">28</span><span class="p">)</span> <span class="k">return</span> <span class="mf">0.2</span><span class="p">;</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">daysAway</span> <span class="o">&gt;=</span> <span class="mi">14</span><span class="p">)</span> <span class="k">return</span> <span class="mf">0.1</span><span class="p">;</span>
  <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Both of our histories came in through CSV importers for the two incumbent apps’ export formats, detected from the header row (<code class="language-plaintext highlighter-rouge">"Set Order"</code> meant Strong; <code class="language-plaintext highlighter-rouge">"Workout,Workout Name"</code> or <code class="language-plaintext highlighter-rouge">"Date (yyyy/mm/dd)"</code> meant StrongLifts) and deduplicated by their order within each date and workout label, because two “Workout A”s on one day is common in real exports. Once the migration was done the importers, 633 lines including tests, were deleted; export gives everything back as CSV from Settings.</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="offline-is-the-default-state-not-an-error-state">Offline is the default state, not an error state</h2>

<p>Gyms are Faraday cages. IndexedDB, the browser’s on-device database (used through the Dexie library), is the source of truth on the device, and the network is an optimisation. The service worker in <code class="language-plaintext highlighter-rouge">public/sw.js</code> caches the app shell and five routes, and the one constant in it that matters is this:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/** Gym dead zones don't fail fetches — they hang them. Anything
 * network-first must give up quickly and fall back to cache. */</span>
<span class="kd">const</span> <span class="nx">NETWORK_TIMEOUT_MS</span> <span class="o">=</span> <span class="mi">3000</span><span class="p">;</span>
</code></pre></div></div>

<p>The rest timer is the other place where “offline” really means “the phone locked in your pocket”. <code class="language-plaintext highlighter-rouge">src/components/RestTimer.tsx</code> does not count down; it saves the moment the rest started under <code class="language-plaintext highlighter-rouge">liftlog.restTimer</code> and, on every tick, works out how many seconds have passed since then (<code class="language-plaintext highlighter-rouge">Math.floor((Date.now() - startTs) / 1000)</code>), so sending the app to the background, navigating away, and a full reload cannot lose it. Closing the timer clears the tick but deliberately not the saved start time. A saved rest older than 30 minutes is treated as stale and discarded. When it rings, it rings with a bell the app synthesises, because a bundled audio file is one more thing to fail offline:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="p">[</span><span class="nx">freq</span><span class="p">,</span> <span class="nx">at</span><span class="p">,</span> <span class="nx">dur</span><span class="p">]</span> <span class="k">of</span> <span class="p">[</span>
  <span class="p">[</span><span class="mf">1318.5</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mf">0.9</span><span class="p">],</span> <span class="c1">// E6</span>
  <span class="p">[</span><span class="mi">880</span><span class="p">,</span> <span class="mf">0.12</span><span class="p">,</span> <span class="mf">1.1</span><span class="p">],</span> <span class="c1">// A5</span>
<span class="p">]</span> <span class="kd">as const</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">osc</span> <span class="o">=</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">createOscillator</span><span class="p">();</span>
  <span class="nx">osc</span><span class="p">.</span><span class="kd">type</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">sine</span><span class="dl">"</span><span class="p">;</span>
  <span class="nx">osc</span><span class="p">.</span><span class="nx">frequency</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">freq</span><span class="p">;</span>
  <span class="nx">gain</span><span class="p">.</span><span class="nx">gain</span><span class="p">.</span><span class="nf">exponentialRampToValueAtTime</span><span class="p">(</span><span class="mf">0.5</span><span class="p">,</span> <span class="nx">t0</span> <span class="o">+</span> <span class="nx">at</span> <span class="o">+</span> <span class="mf">0.015</span><span class="p">);</span>
  <span class="nx">gain</span><span class="p">.</span><span class="nx">gain</span><span class="p">.</span><span class="nf">exponentialRampToValueAtTime</span><span class="p">(</span><span class="mf">0.0001</span><span class="p">,</span> <span class="nx">t0</span> <span class="o">+</span> <span class="nx">at</span> <span class="o">+</span> <span class="nx">dur</span><span class="p">);</span>
</code></pre></div></div>

<p>Two tones per strike, struck twice, half a second apart. The <code class="language-plaintext highlighter-rouge">AudioContext</code> is created inside the tap that logs the set, because browsers only allow sound to start from a user gesture, and resumed before striking, because long workouts suspend it. The screen is kept awake while a workout is open, and the phone gives a 10-millisecond vibration on every logged set.</p>

<h2 id="sync-without-a-database-server">Sync without a database server</h2>

<p>Each lifter gets their own Durable Object, addressed by their lower-cased email, which comes from the <code class="language-plaintext highlighter-rouge">cf-access-authenticated-user-email</code> header that Cloudflare Access sets. The Durable Object handles one request at a time and has its own SQLite database, which makes it the place where that user’s writes are put in order, with no Postgres anywhere. Finished workouts are pushed as operations keyed by workout id, so applying one twice changes nothing; fresh devices pull. Underneath, <code class="language-plaintext highlighter-rouge">src/server/backupStore.ts</code> writes a dated snapshot to R2 under <code class="language-plaintext highlighter-rouge">snapshots/&lt;user&gt;/&lt;stamp&gt;.json</code> and merges a <code class="language-plaintext highlighter-rouge">latest.json</code> pointer with a write that only succeeds if the file has not changed since it was read (an etag check), retried three times, because two devices can finish a backup at once.</p>

<p>Identity is handled entirely by Access, so the app contains no authentication code, and the list of lifters is an environment variable (<code class="language-plaintext highlighter-rouge">NEXT_PUBLIC_LIFTLOG_USERS</code> in <code class="language-plaintext highlighter-rouge">.env.example</code>, a <code class="language-plaintext highlighter-rouge">LIFTLOG_USERS</code> Worker secret in production) that never enters the repo.</p>

<h2 id="the-week-it-lost-a-workout">The week it lost a workout</h2>

<p>On 14 July a real workout was erased from the R2 bucket. <code class="language-plaintext highlighter-rouge">docs/sync-repair-plan.md</code> is the write-up; the commit trail is the short version, and every step of it is a bug that looked like success.</p>

<p>The backup route overwrote <code class="language-plaintext highlighter-rouge">latest.json</code> instead of merging it, so a device with an older local copy wiped out a newer one (#17). The journal only holds workouts finished since it existed, so years of imported history lived in <code class="language-plaintext highlighter-rouge">latest.json</code> and the dated snapshots alone, and snapshots expire after 90 days. One overwrite plus three months is unrecoverable. That is why the merge is now a union, written with the etag check.</p>

<p>Then the outbox, the queue of writes waiting to be sent. Behind Cloudflare Access an expired session returns the login page with a success status (200) from the app’s own domain, and <code class="language-plaintext highlighter-rouge">flushOutbox</code> emptied the queue as soon as the response looked successful, deleting writes the journal never saw (#11). The fix checks the shape of the response body before emptying the queue, and sends an <code class="language-plaintext highlighter-rouge">X-Requested-With</code> header so Access answers with a 401 instead of a login page. The same trap is in the service worker, which now caches a response only if it succeeded, was not redirected, and came from the app’s own origin, because caching a login page poisons the offline shell.</p>

<p>The same commit fixed the reason pulls happen at all during a workout: <code class="language-plaintext highlighter-rouge">applyOp</code> writes <code class="language-plaintext highlighter-rouge">workingWeightKg</code> and <code class="language-plaintext highlighter-rouge">finishWorkout</code> reads it back to decide deloads, so a remote operation landing mid-session could step a lift from five sets of five to three sets of five. Sync is now push-only while a workout is active:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">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">getActiveWorkout</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">)),</span>
</code></pre></div></div>

<p>Repairing the journal needed a reset (#12), and the reset shipped its own bug (#14). Each device remembers how far through the journal it has read as a cursor, the sequence number of the last entry it saw. Rebuilding the journal restarted its sequence numbers at 1 while every device still held a cursor from the old journal.</p>

<p><img src="/images/blog/rampset-cursor-epoch.svg" alt="Two panels: after the journal is rebuilt its sequence numbers restart at 1, but the device still holds a cursor at 912 from the old journal, so asking for entries newer than 912 returns nothing and the device silently never syncs again; with an epoch stamped on the journal, a device that sees a new epoch resets its cursor to zero and replays every entry." /></p>

<p>The journal now carries an epoch number, and a device that sees a new one replays from zero.</p>

<p>Last, identity (#20). The identity check was on <code class="language-plaintext highlighter-rouge">cloudBackup</code> and <code class="language-plaintext highlighter-rouge">syncNow</code>, but <code class="language-plaintext highlighter-rouge">finishFlow</code> called <code class="language-plaintext highlighter-rouge">flushOutbox</code> directly, so finishing a workout as the other lifter on a shared phone posted it to the signed-in lifter’s journal, got a valid acknowledgement, and emptied the outbox. In an append-only log that is permanent. The fix moved the check to the one door every write passes through, and #21 stopped an unreliable <code class="language-plaintext highlighter-rouge">/api/me</code> call (a two-second timeout that a cold mobile network blows routinely) from being read as “no identity, allow everything”: the app now remembers the last identity it actually resolved and falls back to that.</p>

<p>Those four failure modes are what got extracted as <a href="/durable-sync/">durable-sync</a>:</p>

<ul>
  <li>a successful-looking response is not evidence that the write landed;</li>
  <li>a cursor only means something against the epoch it came from;</li>
  <li>pulling remote changes is not always safe;</li>
  <li>the identity check must be the only door.</li>
</ul>

<p>Rampset now uses the package; its own 329-line copy of the journal was deleted (#22), and the class name and SQL schema were kept identical so the live Durable Object carried over without a migration.</p>

<h2 id="tests">Tests</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> Test Files  34 passed (34)
      Tests  256 passed (256)
   Duration  5.22s
</code></pre></div></div>

<p>The engines are pure functions in <code class="language-plaintext highlighter-rouge">src/lib/</code> and each has a test file beside it: progression, deloads, Madcow, plates, estimated one-rep max (one case pins five reps at 115 kg to an estimate of 133.3 kg, a value taken from the real export), the sync journal, backup round-trips, and the <code class="language-plaintext highlighter-rouge">Sheet</code> component whose browser-history handling was the root cause of a finish-flow bug in #11 (<code class="language-plaintext highlighter-rouge">router.replace</code> overwrote the history entry the sheet had pushed, so consuming it on close walked the user back into a fresh workout). The count was 240 at the initial release and has moved with the incident fixes and the importer deletion; 256 is what runs today.</p>

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

<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"># vitest — the engines, store, sync, components</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">npm run deploy</code> builds with the OpenNext adapter (which packages a Next.js app for Cloudflare) and ships to Cloudflare Workers; <code class="language-plaintext highlighter-rouge">wrangler.jsonc</code> declares the <code class="language-plaintext highlighter-rouge">SYNC_JOURNAL</code> Durable Object (a <code class="language-plaintext highlighter-rouge">new_sqlite_classes</code> migration) and the <code class="language-plaintext highlighter-rouge">BACKUPS</code> R2 bucket. Put a Cloudflare Access application in front of the Worker and allow your lifters’ emails. Workers and Access are free for a household. The one paid gate is <strong>R2</strong>: the data sits well inside the 10 GB free tier, but Cloudflare wants a card on file to enable it. Without it, drop the <code class="language-plaintext highlighter-rouge">BACKUPS</code> binding; the app works fully offline on the local IndexedDB store, and the <strong>Export</strong> button in Settings hands you a complete CSV whenever you want.</p>

<h2 id="what-i-would-tell-the-subscription-app">What I would tell the subscription app</h2>

<p>The migration cost me a Saturday and the incident cost me a week, and the week was the useful part. Every bug in it returned a 200. A loyal customer with an export file is no longer locked in by anything, and with a coding agent in the loop “I’ll build it myself” is a weekend, not a threat. The two proven programs, the plate math and the sync are all in a public repo under a licence that says make it yours. 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 barbell training web app that works offline first — guided five-by-five and Madcow programs, freeform routines, a per-user Durable Object with its own SQLite for sync, R2 snapshots — built after the app holding my StrongLifts history cancelled my subscription during a support ticket. 256 tests, MIT, self-host on Cloudflare.]]></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></feed>