<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dominique Siacci</title>
    <description>The latest articles on DEV Community by Dominique Siacci (@dsiacci).</description>
    <link>https://dev.to/dsiacci</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4022852%2F590c4faa-3278-45ea-801d-217de0c50e0a.jpg</url>
      <title>DEV Community: Dominique Siacci</title>
      <link>https://dev.to/dsiacci</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dsiacci"/>
    <language>en</language>
    <item>
      <title>One letter higher: checking my saddle height with a side-view video, in the browser</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Fri, 16 Oct 2026 11:37:41 +0000</pubDate>
      <link>https://dev.to/dsiacci/one-letter-higher-checking-my-saddle-height-with-a-side-view-video-in-the-browser-5dng</link>
      <guid>https://dev.to/dsiacci/one-letter-higher-checking-my-saddle-height-with-a-side-view-video-in-the-browser-5dng</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My road bike got a professional fit last year. My home trainer, a Zwift Ride, is set with letters, A to X, and I didn't know whether the letter my saddle sits on was the right one. On Thursday evening I put my phone on a tripod, two or three metres from the bike on the drive side, and pedalled for two minutes. Then I raised the saddle by one letter and did it again.&lt;/p&gt;

&lt;p&gt;I'm an amateur cyclist, not a bike fitter. What I wanted was a number for one question, from what a phone video can actually measure.&lt;/p&gt;

&lt;p&gt;Saddle Check is a web page. You drop a video of yourself pedalling, filmed from the side. An open pose model, MediaPipe Pose, runs in the browser, on your own computer or phone, and the video is never uploaded. The page gives you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the knee bend at the bottom of the pedal stroke, stroke by stroke, against the published range for riders measured while pedalling (33 to 43°);&lt;/li&gt;
&lt;li&gt;the saddle change in millimetres, and in letters or notches if you give their spacing, that would bring the knee to the middle of that range;&lt;/li&gt;
&lt;li&gt;the other angles (hip, trunk, shoulder, elbow, ankle), next to book or manufacturer guidelines, labelled as such: inside or outside, never "good" or "bad";&lt;/li&gt;
&lt;li&gt;an animated skeleton on a plain background (never the video picture, unless you ask), the curve of each angle through the stroke, and a report to download.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Who would pay?&lt;/strong&gt; A bike fit costs from €90 (static) to €399 (full) in the French shops I checked. Apps exist: FramIQ, on iPhone, also analyses the video on the device, for $25 per analysis or $79 a year; MyVeloFit uploads the video to its servers, for $35 or $75 a year. Saddle Check is a prototype, but a paid version could find its place in that landscape, below those prices and with nothing to install: a report to bring to a bike shop, a follow-up after each adjustment, a version for a club that shares indoor bikes. The rider's own device does the computing, so an analysis costs almost nothing to serve, and even a low price would leave a margin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/JUidqkAi33U" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The demo uses the page's synthetic rider, so no real person appears in it. To run the page yourself, the README has the three commands that serve it from your own machine; the same synthetic-rider button is there when you have no video at hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/dsiacci" rel="noopener noreferrer"&gt;
        dsiacci
      &lt;/a&gt; / &lt;a href="https://github.com/dsiacci/bikefit" rel="noopener noreferrer"&gt;
        bikefit
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Saddle Check&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Bike fit angles from a side-view phone video, measured in the browser. You drop a video of yourself pedalling; an open-weight pose model (&lt;a href="https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker" rel="nofollow noopener noreferrer"&gt;MediaPipe Pose Landmarker&lt;/a&gt;) runs inside the page and finds your joints in every frame; the page measures your knee, hip, ankle, trunk, shoulder and elbow angles at every pedal stroke, compares the medians with published ranges, and turns the knee angle into a saddle change in millimetres.&lt;/p&gt;
&lt;p&gt;The video never leaves your device. The page and the model are static files; once the model is loaded, the page locks its own network access (see &lt;a href="https://github.com/dsiacci/bikefit#privacy" rel="noopener noreferrer"&gt;Privacy&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Demo video, with the page's synthetic rider: &lt;a href="https://youtu.be/JUidqkAi33U" rel="nofollow noopener noreferrer"&gt;youtu.be/JUidqkAi33U&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Use it&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The page is a set of static files. To serve it from your machine:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm ci
npm run build   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; copies the MediaPipe runtime, downloads the models, checks their SHA-256&lt;/span&gt;
npm run serve   &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; http://127.0.0.1:8080&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;How&lt;/h3&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/dsiacci/bikefit" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Plain JavaScript, no framework, no runtime dependency besides MediaPipe, Apache 2.0, 32 tests. The measuring code (&lt;code&gt;web/core/&lt;/code&gt;) is the same for the page and for the scripts that produced the numbers in this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reading every frame, without sending it.&lt;/strong&gt; The page reads the video frame by frame with WebCodecs, the browsers' decoding API, fed by a small reader for MP4 and QuickTime boxes: each frame is decoded once, in order. My first version asked a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element for each frame, which decodes again from the last key frame: 226 s for a 14 s clip, against 36 s now, with the same numbers. Each frame goes through MediaPipe Pose Landmarker, Google's open-weight pose model (Apache 2.0, the "full" variant), which returns 33 body landmarks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cutting the network, and proving it.&lt;/strong&gt; Once the model is loaded, the page adds a security policy to itself that forbids every connection (&lt;code&gt;connect-src 'none'&lt;/code&gt;), then checks that it holds: it tries to read a file from its own site and makes sure the browser refuses. A padlock shows in the header. In Chromium's console, that reads: "Connecting to '…/lock-probe.txt' violates the following Content Security Policy directive: "connect-src 'none'". The action has been blocked." Anyone can check it in their own browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The crank is the ruler.&lt;/strong&gt; Millimetres need a scale, and it's in the picture: with cleats, the shoe is fixed to the pedal, and the pedal axle is the only point of the foot that turns in a circle around the bottom bracket. The page searches the frame of the foot (the heel and toe landmarks) for the point whose path is the most regular circle. Its radius in pixels is the crank: 170 mm on my bike.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The right leg.&lt;/strong&gt; Seen from the side, the model sometimes swaps the left and right labels of the legs. The page follows the leg nearest to the camera by continuity and corrects those swaps (12 to 18 per video in mine).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One pedal stroke.&lt;/strong&gt; Positions are cleaned (unsure landmarks dropped, a zero-lag 6 Hz low-pass filter), the crank angle splits the video into strokes, top to top, and the angles are read at the bottom. The page keeps the median over clean strokes, with its confidence interval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranges, with their level of evidence.&lt;/strong&gt; For the knee at the bottom of the stroke while pedalling: 33 to 43° of bend (&lt;a href="https://doi.org/10.37190/ABB-01428-2019-02" rel="noopener noreferrer"&gt;Millour et al. 2019&lt;/a&gt;; &lt;a href="https://doi.org/10.1249/JSR.0000000000000665" rel="noopener noreferrer"&gt;Swart &amp;amp; Holliday 2019&lt;/a&gt;); that range drives the saddle change. The most quoted range, 25 to 35° (Holmes), is measured standing still with a goniometer; pedalling bends the knee about 8° more. For the hip, the trunk, the shoulder or the elbow, I only found book or manufacturer guidelines: the page shows them without saying "good" or "bad".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The saddle change.&lt;/strong&gt; The thigh, the shank and the hip-to-ankle line make a triangle. The law of cosines says how much longer that line must get for the knee to reach the middle of the range, and the page turns it into millimetres along the seat post.&lt;/p&gt;

&lt;p&gt;I built it this week with an AI coding agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does It Measure Anything?
&lt;/h2&gt;

&lt;p&gt;Five videos at 60 frames per second, tripod and bike taped to the floor: V1, V2 (after getting off and back on) and V4 (saddle set back to its letter) at the same height; V3 one letter higher, 10 mm; S, still, crank down, to compare with an inclinometer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Video&lt;/th&gt;
&lt;th&gt;Saddle&lt;/th&gt;
&lt;th&gt;Knee bend at the bottom (median, 95 % interval)&lt;/th&gt;
&lt;th&gt;Clean strokes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;V1&lt;/td&gt;
&lt;td&gt;start&lt;/td&gt;
&lt;td&gt;42.3° (41.8 to 42.5)&lt;/td&gt;
&lt;td&gt;114 of 133&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V2&lt;/td&gt;
&lt;td&gt;start&lt;/td&gt;
&lt;td&gt;43.0° (42.6 to 43.5)&lt;/td&gt;
&lt;td&gt;76 of 92&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V4&lt;/td&gt;
&lt;td&gt;start&lt;/td&gt;
&lt;td&gt;42.8° (42.2 to 43.1)&lt;/td&gt;
&lt;td&gt;96 of 115&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V3&lt;/td&gt;
&lt;td&gt;+10 mm&lt;/td&gt;
&lt;td&gt;37.9° (37.3 to 38.2)&lt;/td&gt;
&lt;td&gt;122 of 134&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Repeatability.&lt;/strong&gt; Three videos at the same height, one after getting off the bike and one after resetting the saddle: 0.7° between the lowest and the highest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensitivity.&lt;/strong&gt; One letter higher, the knee bends 4.8° less (95 % interval 4.3 to 5.4). The leg triangle predicted 3.4°. The direction and the size are right. My ankle at the bottom didn't change clearly, but my hip moved 12.4 mm in the picture for a 10 mm saddle change: I may have sat a little differently, or the scale is a few percent off, and these videos can't tell which.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The page called it, this time.&lt;/strong&gt; On my three starting videos, it suggested raising the saddle by about one letter (11 mm) to reach the middle of the range, 38°. One letter higher, it measured 37.9°.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reference.&lt;/strong&gt; Still, crank down, the model reads a knee bend of 33.2°. My inclinometer (the level of the iPhone Measure app held along the thigh, then the shank, read by me, alone on the bike) gives 34°, and the software of last year's fit, made for my road bike, said 34° too. Pedalling, the page measures 42.7°, 9.4° more than still, close to the 8° the studies describe. One thing doesn't match: the thigh and the shank taken one by one differ by 11 and 12° between the model and the inclinometer, turned the same way, which cancels out in the knee angle. One of the two was tilted by about 12°, my readings or the camera's horizon, and these videos can't tell which.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What these numbers don't say.&lt;/strong&gt; My 0.7° says the page is consistent, not that it's right to 0.7°: the only study of MediaPipe in cycling I found (&lt;a href="https://doi.org/10.1007/s11332-023-01075-7" rel="noopener noreferrer"&gt;Bini, Nascimento &amp;amp; Nibali 2024&lt;/a&gt;) reports typical errors of 3 to 6° at the knee.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Went Wrong
&lt;/h2&gt;

&lt;p&gt;I had tuned the threshold that decides "this point of the foot really turns in a circle" on synthetic riders, where the path stays within 2 % of a circle. On my real videos, the foot landmarks the model returns stray from the circle by about a tenth of the crank. The page refused the pedal axle and fell back on the loop of the ankle: no millimetres, and a badly placed bottom of the stroke, with a knee at 39° instead of 42°.&lt;/p&gt;

&lt;p&gt;Looking at the numbers, I saw that this scatter changes from one stroke to the next, while a foot sliding on a pedal bends the loop the same way every time. So the page now averages the pedal path angle by angle over dozens of strokes before judging the circle, and it still refuses a synthetic foot sliding 70 mm. The radius it finds still varies by 5 % from one video to another, so the page gives its millimetres to within 10 %. The angles don't depend on the scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An open model can go where the video is.&lt;/strong&gt; Because the model is open, the page can run it on the rider's own device instead of a server, so the video can stay there. The page also switches off its own network once the model is loaded: the browser console shows it, and the code shows the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An analysis costs almost nothing.&lt;/strong&gt; The model runs on the rider's device and the page is a set of static files: there is no computing server to pay for, which is what would keep a paid version cheap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No lock-in.&lt;/strong&gt; The code is under Apache 2.0, the report is an HTML file you keep, and the ranges are listed with their sources: a bike shop can check where every number comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of DigitalOcean.&lt;/strong&gt; A DigitalOcean droplet ran the page itself in headless Chromium for the many automated tests: every number in this post comes from those runs on my five videos, and the demo video was recorded there too. The repository has the scripts to do it again.&lt;/p&gt;




&lt;p&gt;So, will I raise my saddle? The page's answer is about one letter: at my current height, my knee bends 42.7° at the bottom of the stroke, inside the published range but near its top; one letter higher (V3), it bends 37.9°, right in the middle. I'm not sure yet that I'll do it: sitting still, my trainer already matches last year's fit, and 42.7° is still inside the range.&lt;/p&gt;

&lt;p&gt;How did you set your saddle height, and would you move it by a centimetre because a video told you to?&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>964 hours of agent work in six days. Here's where my own hours went.</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:09:45 +0000</pubDate>
      <link>https://dev.to/dsiacci/964-hours-of-agent-work-in-six-days-heres-where-my-own-hours-went-13oc</link>
      <guid>https://dev.to/dsiacci/964-hours-of-agent-work-in-six-days-heres-where-my-own-hours-went-13oc</guid>
      <description>&lt;p&gt;On October 7 I dictated the plan to an agent: we would start an autonomous loop, I would close my session, come back a few days later, and the 200 templates of our app designer would be done.&lt;/p&gt;

&lt;p&gt;The last check came back green on the morning of October 15: 12,074 end-to-end cases, zero gaps. In between, 28 coding agents worked on it, up to 20 at a time, for 964 hours of session time in total, with more than 6,000 commits and 717 deployments to our dev environment. The 200 templates turned out to be 569 units of work. And I never really closed the session.&lt;/p&gt;

&lt;p&gt;I'm the CTO of GoodBarber, an app platform. The agents wrote the code. This post is about where my own hours went, because that's the part I had wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job
&lt;/h2&gt;

&lt;p&gt;In our back-office, the logic that applies a setting sits with the part of the UI that edits it. The refactor moves that logic into documented functions with typed options, which the UI and a new API both call, and proves along the way that nothing changes for the people using it.&lt;/p&gt;

&lt;p&gt;The unit of work is an abstraction: something that exists in the UI and defines a set of controls over one kind of entity, in one layout variant. A script cut the whole app designer into 552 units 13 minutes after I dictated the rules for the cut, then 569 after a second pass.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;The numbers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Design&lt;/td&gt;
&lt;td&gt;2 weeks, Sep 23 to Oct 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;6 days, Oct 9 to Oct 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Units&lt;/td&gt;
&lt;td&gt;569: 445 done, 124 dropped or merged, mostly duplicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controls&lt;/td&gt;
&lt;td&gt;about 20,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agents&lt;/td&gt;
&lt;td&gt;28 workers, up to 20 at a time, and one integrator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session time&lt;/td&gt;
&lt;td&gt;964 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commits&lt;/td&gt;
&lt;td&gt;more than 6,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployments to dev&lt;/td&gt;
&lt;td&gt;717&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final campaign&lt;/td&gt;
&lt;td&gt;12,074 cases, 0 gaps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Two weeks before the six days
&lt;/h2&gt;

&lt;p&gt;Before those six days, I spent two weeks on the design, from September 23 to October 7. Most of it ran at Claude Opus 5.5's highest effort level (max), with around 200 sub-agents exploring the code and reviewing proposals. For the first week I also had ultracode on, a setting where the model plans a multi-agent workflow for each substantial task. On the first day, 32 agents spent five hours on the strategy alone: 18 explored, 5 wrote competing proposals, 3 judged them, 1 combined the best of them, 2 attacked the result and 3 ran the final checks.&lt;/p&gt;

&lt;p&gt;I used to think of the loop as the project. In practice it was the last part, and it held because the two weeks before had settled what a finished unit looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "done" meant
&lt;/h2&gt;

&lt;p&gt;The checks were the review. A unit went to our dev environment when they passed, and merging into production code stays a human step.&lt;/p&gt;

&lt;p&gt;All the checks prove one thing from different sides: for every control and every test value, the new code path leaves the application in exactly the same state as the old one, the change can be undone exactly or is listed as an exception, and the API behaves like the UI. This is how often each one caught something:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Units where it caught something at least once&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A headless browser performs the real gesture, and the test bench must send exactly the same request&lt;/td&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A snapshot of the whole application before and after each test value, old path against new, then an exact undo and redo&lt;/td&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The API must do what the UI does, read back what it wrote, and reject invalid input without side effects&lt;/td&gt;
&lt;td&gt;114&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A plain-language request written by one agent, run by another that only sees the documentation, must end in the expected state&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The final campaign on the deployed code, through real gestures and through the API&lt;/td&gt;
&lt;td&gt;0 gaps in 12,074 cases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When a check failed three times, the unit was parked on a question or marked as blocked, and the worker moved on. In the final campaign, 48 cases were impossible, each one traced to the line of code that makes it so.&lt;/p&gt;

&lt;h2&gt;
  
  
  One unit, then ten, then twenty
&lt;/h2&gt;

&lt;p&gt;I didn't start with twenty.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Scale&lt;/th&gt;
&lt;th&gt;What came out of it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oct 7–8&lt;/td&gt;
&lt;td&gt;Step 0&lt;/td&gt;
&lt;td&gt;1 unit, in a guided session&lt;/td&gt;
&lt;td&gt;11 changes to the process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 8&lt;/td&gt;
&lt;td&gt;Step 1&lt;/td&gt;
&lt;td&gt;10 units in one loop, one after the other&lt;/td&gt;
&lt;td&gt;8 more changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 9&lt;/td&gt;
&lt;td&gt;Pilot&lt;/td&gt;
&lt;td&gt;4 workers and an integrator&lt;/td&gt;
&lt;td&gt;16 improvements, live during the first wave&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 10&lt;/td&gt;
&lt;td&gt;Step 2&lt;/td&gt;
&lt;td&gt;8 workers&lt;/td&gt;
&lt;td&gt;first weekend: 183 units, 930 commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 11, evening&lt;/td&gt;
&lt;td&gt;Step 2&lt;/td&gt;
&lt;td&gt;20 workers&lt;/td&gt;
&lt;td&gt;overnight into Monday: 173 units in 11 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The conditions to go from four to eight were written down before Saturday: the same proofs as when units ran one after the other, no worker touching another worker's test app, no two deployments overlapping. Between the two waves of the pilot, the same four workers got much faster, mostly thanks to fixes made during the first wave, like a pre-check that runs the tests on a throwaway copy before asking for integration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pilot&lt;/th&gt;
&lt;th&gt;Wave 1&lt;/th&gt;
&lt;th&gt;Wave 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Median time per unit&lt;/td&gt;
&lt;td&gt;2 h&lt;/td&gt;
&lt;td&gt;54 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integration rejections&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median wait for integration&lt;/td&gt;
&lt;td&gt;6 min&lt;/td&gt;
&lt;td&gt;0.8 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How it was organized
&lt;/h2&gt;

&lt;p&gt;One session, the integrator, never takes a unit. It's the only one allowed to merge and deploy, and it fills the queue, reopens units and keeps the dashboard up to date. The workers pull: each one asks the queue for the next unit compatible with its own test app, works on its own branch in its own worktree, and asks for integration when its checks pass. Integration runs in batches of up to six, with one pre-check and one deployment per batch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipmxlf465pbtz7tyyr1w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipmxlf465pbtz7tyyr1w.png" alt="Diagram: workers, up to 20 at a time, pull units from a queue and ask one integrator for integration. The integrator is the only one that merges into git and deploys to the dev environment, and it keeps the queue and the dashboard where I answer questions." width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing goes through chat. The queue, git and a dashboard carry the whole protocol. The protocol itself is a file in git that every session re-reads at each round, so I could change the workers' instructions 53 times during the project without restarting anyone. When a worker hasn't sent a heartbeat for 45 minutes, its unit goes back to the queue and someone else picks it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where my hours went
&lt;/h2&gt;

&lt;p&gt;Two weeks of design, first. Then the questions.&lt;/p&gt;

&lt;p&gt;A worker that hit a case it couldn't decide alone didn't wait for me. It wrote a question on the dashboard with the context, two or three options, what each one would cost, and its recommendation, then parked that unit and took the next one. It decided alone on anything reversible that users don't see. It had to ask about an architecture choice, a behavior it couldn't prove identical, a proof that needed to break the loop's own rules, or the scope.&lt;/p&gt;

&lt;p&gt;There were 88 questions, from the first loop on October 8 to the end. Of the 59 that had both a recommendation and an answer from me, I followed the recommendation 47 times. One I didn't follow: proving one control meant calling a third-party service, which the loop's rules don't allow from a test. The recommended option was to simulate the service, another was to leave that control out of the API, and the third was to call the real service from a test app and break the rule. The question came in at 13:41, I picked the third option at 13:48, and it was applied at 13:53. That's the kind of decision I wanted to keep for myself.&lt;/p&gt;

&lt;p&gt;From the first evening, a case that looked like one I had already answered stopped waiting too. The worker applied the precedent, logged it, and I validated or rejected it afterwards. There were 185 of those: 166 validated, 19 rejected. Ten of the 19 rejections were about the same thing, an agent excluding something that resisted. That one deserves its own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision fatigue
&lt;/h2&gt;

&lt;p&gt;Put end to end, my answers would fit in an afternoon, somewhere between three and a half and five hours. That number is the misleading part.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fefg48vuhqy2hkueoftje.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fefg48vuhqy2hkueoftje.png" alt="Bar chart of my answers and checks on the dashboard per day, Oct 8 to Oct 15 (Paris time): 9, 11, 37, 3, 108, 69, 17, 2. 63 of Monday's 108 came in one hour." width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;They didn't come in one afternoon. They came all day, every day, and they piled up whenever I looked away. On Sunday I spent the day unblocking workers by hand and barely touched the dashboard. On Monday I cleared the pile, 63 decisions in one hour, about one a minute, most of them precedents to check. Each one was small and well framed, with its options and a recommendation, and that's what makes them tiring in bulk: by the fiftieth, the recommendation is the easy way out.&lt;/p&gt;

&lt;p&gt;I rejected more precedents that Monday than on any other day, 13 out of 95, so I don't think I was rubber-stamping. But over the week I followed the recommendation 47 times out of 59, and from the logs I can't tell which of those were judgment and which were fatigue. I'll come back to that in a separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The test bench lied on day one.&lt;/strong&gt; It built its own requests instead of sending what the UI sends. I tested a migrated control by hand and it rejected every color coming from the color picker, which sends them without a "#". Since then a real browser replays the gestures, and the bench has to send exactly what the UI sends. That browser found format differences on about a hundred more controls along the way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The single integrator became the bottleneck.&lt;/strong&gt; Every integration went through one session, and the wait grew with the load (table below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A worker went silent for almost three days, and nothing told me.&lt;/strong&gt; Heartbeats were tracked for units in progress, so a worker holding no unit was invisible to the integrator. The others kept going.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents drop what resists.&lt;/strong&gt; On Tuesday, an audit run by one of the workers found 516 controls left on the old path without any decision. I had all of them brought back into scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rule nothing checked got broken.&lt;/strong&gt; One worker removed a line from a file that the rules say is append-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Integration requests&lt;/th&gt;
&lt;th&gt;Median wait&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fri Oct 9&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;0.9 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sat Oct 10&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;1.0 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sun Oct 11&lt;/td&gt;
&lt;td&gt;145&lt;/td&gt;
&lt;td&gt;2.9 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mon Oct 12&lt;/td&gt;
&lt;td&gt;251&lt;/td&gt;
&lt;td&gt;11.0 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tue Oct 13&lt;/td&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;td&gt;17.5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wed Oct 14&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;1.6 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At the worst hour, early on Sunday morning, the median wait passed an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still true
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The merge into our production code is still mine to do, after my own tests by hand. A loop never merges into production.&lt;/li&gt;
&lt;li&gt;132 changes that can't be undone automatically are listed, not solved, and I have to read them before anything ships.&lt;/li&gt;
&lt;li&gt;What I'd do again: design at the highest effort, one automatic proof per unit and an end-to-end final campaign, scaling in steps with a review at each one, and questions that ask for a rule rather than a case. What I'd watch: the single integrator, the scope, my own decision load, and the pulse of every session, not just of every unit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've run several coding agents in parallel on something real, where did your own hours go? Mine went into two weeks of design and more than 250 small decisions. I'll answer in the comments with how the queue and the dashboard work.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>Will the fountains on my ride be running? I asked 3,831 stream observations</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Fri, 09 Oct 2026 07:27:56 +0000</pubDate>
      <link>https://dev.to/dsiacci/will-the-fountains-on-my-ride-be-running-i-asked-3831-stream-observations-24e8</link>
      <guid>https://dev.to/dsiacci/will-the-fountains-on-my-ride-be-running-i-asked-3831-stream-observations-24e8</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;There's a fountain on the road just before Marato, a stone basin behind a small railing, that I count on when I ride south of Ajaccio. It runs well. OpenStreetMap doesn't have it. IGN, the French national mapping agency, does, but 55 m away, in the woods.&lt;/p&gt;

&lt;p&gt;I ride the same roads, slowly, and I know which of their fountains run in September. Riders passing through don't. OpenStreetMap lists 608 places to fill a bottle in Corsica; 2 of them say whether they dry up in summer, and 33 carry a date when someone last checked them. The maps know roughly where the fountains are. Nobody writes down whether they are running.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;code&gt;fountains&lt;/code&gt;, a small open-source tool that runs on a laptop. You drop the GPX of your ride on a local page, and:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;it lists every fountain OpenStreetMap or IGN knows within 250 m of the route;&lt;/li&gt;
&lt;li&gt;it adds the places where open data hints at a fountain no map has: a spring IGN puts next to the road, a stream crossing under it;&lt;/li&gt;
&lt;li&gt;it shows each place on photos taken from the road, an open vision model putting first the views where it sees something like a fountain, and &lt;strong&gt;you&lt;/strong&gt; decide: fountain, not a fountain, or not sure. When no view shows it, you look around in 360°, walk along the road from photo to photo, and click the foot of the fountain to put it where it really is;&lt;/li&gt;
&lt;li&gt;the fountains you keep get a band for the day of the ride, &lt;strong&gt;likely&lt;/strong&gt;, &lt;strong&gt;uncertain&lt;/strong&gt; or &lt;strong&gt;unlikely&lt;/strong&gt; to be running, with the reason: the rain of the last weeks compared with normal, the forecast, the altitude, the nearest stream someone actually walked to;&lt;/li&gt;
&lt;li&gt;on long stretches without a likely fountain, it suggests a café, a bakery or a fuel station open when you pass, about every 10 km; a fountain you know runs cuts those stretches;&lt;/li&gt;
&lt;li&gt;one GPX goes onto the bike computer, with waypoints named like &lt;code&gt;likely: Funtana di Leccia&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a 28 km ride, the preparation takes about twenty minutes of the laptop working alone the evening before, then a few minutes of looking at photos. The rest happens on the road.&lt;/p&gt;

&lt;p&gt;One rule is printed in every output of the tool, and I'll repeat it here: &lt;strong&gt;a score is never a reason to leave with less water.&lt;/strong&gt; Carry what you need, as if every fountain were dry. The tool also says nothing about whether the water is safe to drink.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/AjhbtetNCFE" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The video follows a real preparation, on my laptop: a 28 km out-and-back to La Parata. The 19 minutes in which the tool finds the points, the clues and the photos, and the vision model ranks every view, go by in ten seconds. The rest shows the cases I ran into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;at the Fontaine des Calanques, the model boxes a red fire hydrant: it ranks photos, it decides nothing;&lt;/li&gt;
&lt;li&gt;at Funtana di Mela, an old stone fountain, I mark it on the photo, and the GPX puts it 22 m from IGN's point;&lt;/li&gt;
&lt;li&gt;the places found from clues, mostly streams under the road, take one answer each: none of them was a fountain;&lt;/li&gt;
&lt;li&gt;for Saturday, none of the four fountains I kept comes out &lt;strong&gt;likely&lt;/strong&gt;: 28 km without a likely fountain, so the tool lists five shops open when I pass; marking the three fountains I know run brings the dry stretch down to 13 km.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykp891520jnfnxi9nh16.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykp891520jnfnxi9nh16.jpg" alt="Funtana di Leccia on the check screen: the best view, the model's box at 0.33, and the pin of the marked position, 55 m from IGN's point" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the fountain before Marato, on my usual 70 km loop. The tool took the photos of the road around IGN's point, and the view the model ranks first is the fountain, seen from the road about twenty metres away. Marked on two photos taken 29 m apart, it lands 4 m from the road: the two lines of sight cross inside the box the model had drawn. IGN's point is 55 m away, in the woods.&lt;/p&gt;

&lt;p&gt;On that loop, the maps know 7 water points within 250 m of the road. The clues add 29 places, 24 of them streams under the road. On 18 of them, the model sees nothing above 0.2; the page sets them aside (a switch brings them back), which leaves 18 points to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/dsiacci" rel="noopener noreferrer"&gt;
        dsiacci
      &lt;/a&gt; / &lt;a href="https://github.com/dsiacci/fountains" rel="noopener noreferrer"&gt;
        fountains
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Will the fountains on your ride be running? Finds the fountains along a GPX in Corsica, checks them on street photos, bands them with TabPFN. Runs on your laptop.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;fountains&lt;/h1&gt;
&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A score is never a reason to leave with less water.&lt;/strong&gt; Carry what you need for the whole ride as if every fountain were dry. This tool says nothing about whether the water is safe to drink.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Will the fountains along your ride be running on the day you ride?&lt;/p&gt;
&lt;p&gt;You export your route as a GPX file and drop it on a local page. It lists every fountain that OpenStreetMap or IGN (the French national mapping agency) knows within a short detour of the route, adds the places where clues in open data suggest a fountain no map has, and shows each one on street photos so you can say which are real. The fountains you keep get a band (&lt;strong&gt;likely&lt;/strong&gt;, &lt;strong&gt;uncertain&lt;/strong&gt; or &lt;strong&gt;unlikely&lt;/strong&gt; to be running) for the day you ride, with the reason and the date anyone last checked them. On long stretches without…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/dsiacci/fountains" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Python, Apache 2.0, 97 tests. &lt;code&gt;fountains serve&lt;/code&gt; opens the page, &lt;code&gt;fountains score&lt;/code&gt; does the same computation from the command line, and a Docker image runs the page on macOS or Windows. Every data source with its license, and the limits of the tool, are in the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fountains on the maps.&lt;/strong&gt; OpenStreetMap, through the Overpass API: &lt;code&gt;amenity=drinking_water&lt;/code&gt; and &lt;code&gt;water_point&lt;/code&gt;, plus springs, taps and fountains tagged &lt;code&gt;drinking_water=yes&lt;/code&gt;. And IGN's topographic database, open since 2021, which knows 1,493 fountains in Corsica; only 250 of them have an OpenStreetMap point within 30 m. Each point is attached to the nearest road or path, and the detour is measured along the network: a fountain 40 m from the road can be 400 m away if the only access is a loop through the village.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fountains on no map.&lt;/strong&gt; Two kinds of clues, both in open data: IGN springs, wash houses and water points close to a paved road, and the places where a stream crosses one, because a spout is often built where the slope brings water down to the road. For each place, the tool takes the 360° photos within 100 m on Panoramax, the open street-imagery commons (in Corsica, mostly IGN's 2025 captures), cuts them into views of both roadsides, and asks OWLv2, an open-vocabulary object detector, for "a water fountain", "a water spout", "a stone water trough", "a water tap". Boxes touching the bottom of a view are ignored: that's where the bonnet of IGN's car shows. The model decides nothing; it sorts photos. Near Marato, its first view was my fountain; elsewhere, it took a fire hydrant or a decorative urn for a fountain. So I look at the photos, and I don't trust the score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Putting the fountain where it is.&lt;/strong&gt; A map can be fifty metres off, and a GPX that sends you into the woods is no use. When I click the foot of a fountain on a photo, the tool knows where the photo was taken and the direction of the click: that's a line of sight. Two photos give two lines, which cross at the fountain. With one, the distance is guessed from the angle below the horizon, for a camera on a car roof; the page says it's a guess, and the pin can be dragged on the map.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rain.&lt;/strong&gt; Météo-France opened all its public data on 1 January 2024, including the daily rain gauges: 103 gauges in Corsica have reported since 2010, 52 still do. The tool interpolates the rain at each fountain from the three nearest gauges, compares the last 30, 90 and 180 days with the 1991-2020 normal, and adds the Météo-France forecast (through Open-Meteo) for the days between the last report and the ride.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Labels.&lt;/strong&gt; This is the part I didn't have. Nobody publishes whether fountains run, but France publishes whether small streams run: every summer since 2012, agents of the French Office for Biodiversity walk to the same points on small streams and write down what they see, from flowing to dry. The network is called ONDE. In Corsica that is 33 streams and 3,831 observations. I kept one question, &lt;em&gt;is flowing water visible?&lt;/em&gt;, because a fountain only fills a bottle if water flows, and because it's the only split the observers used the same way every year (from 2016 to 2019 they didn't separate normal flow from weak flow).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model.&lt;/strong&gt; TabPFN, from Prior Labs, is a foundation model for tabular data: a transformer pre-trained on millions of synthetic tables. You give it the 3,831 rows as context (day of the year, altitude, rain over 30, 90 and 180 days, how the last 90 and 180 days compare with normal, and whether water was flowing), then the new rows, and it returns a probability for each in one forward pass. No training loop, no hyperparameters to tune. I use the open v2 weights, on the CPU: scoring a ride takes half a minute on my laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much to trust it.&lt;/strong&gt; I hid each stream in turn and asked the model about it, given only the other 32. Over the 3,831 hidden observations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Brier score (lower is better)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TabPFN&lt;/td&gt;
&lt;td&gt;0.114&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logistic regression, same inputs&lt;/td&gt;
&lt;td&gt;0.115&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The rate for the month&lt;/td&gt;
&lt;td&gt;0.127&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always "83 % flowing"&lt;/td&gt;
&lt;td&gt;0.141&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TabPFN is a little better than a logistic regression on the same inputs, by a margin too small to matter, and both beat the calendar by about 10 %. The ratios to normal earned their place: without them, TabPFN scored 0.118.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk7pbtlse88zfwg5vlkg1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk7pbtlse88zfwg5vlkg1.png" alt="Reliability curve: the share of hidden observations actually flowing, against the probability TabPFN gave them" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On streams it has never seen, its probabilities are too sure of themselves: cases it scored around 0.85 were flowing 72 % of the time, cases around 0.15 were flowing 43 % of the time. So I fixed the rule for the bands before the first run, and let the hidden streams set their limits: &lt;strong&gt;likely&lt;/strong&gt; where at least 90 % of the hidden cases were flowing, &lt;strong&gt;unlikely&lt;/strong&gt; where at most 50 % were. In the validation, "likely" streams were flowing 1,917 times out of 1,965, "uncertain" ones 1,232 times out of 1,734, "unlikely" ones 43 times out of 132. That's why the tool shows bands and not percentages: a percentage measured on streams would claim more than I know about fountains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The weak link.&lt;/strong&gt; A fountain is not a stream. The model assumes a fountain fed by a spring dries like a small headwater stream after the same weather. That's plausible for a village on a shallow spring, wrong for a tap on the town mains, and unknown for a deep spring that reacts months later. The page says so, and you can mark a fountain you know runs: it then counts as water, whatever the model says.&lt;/p&gt;

&lt;p&gt;On Saturday I'm riding the route from the video again, to La Parata, and I'll update this post with what the four fountains actually did.&lt;/p&gt;

&lt;p&gt;I built it this week with an AI coding agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Every piece of this is open, and none of it would exist otherwise. The rain is measured by Météo-France and free to reuse since 2024. The stream observations are walked and written down by public agents and published under an open licence. The fountains were mapped by volunteers on OpenStreetMap and by IGN, whose database and street photos are open too. The models are open, so the whole thing runs on my laptop, with no account and nothing to pay, and it will give the same answer next year. Nobody would sell an API that says whether the fountain in a Corsican village is running; the market is a few hundred cyclists and hikers.&lt;/p&gt;

&lt;p&gt;And it can go back. When a rider sees a fountain running or dry, the right place for that is OpenStreetMap's &lt;code&gt;check_date&lt;/code&gt; and &lt;code&gt;seasonal&lt;/code&gt; tags, and a fountain found on the photos belongs on the map too, where the next person (and the next version of this tool) will find it. The tool never edits the map itself; that stays a human's call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of TabPFN.&lt;/strong&gt; TabPFN is the model that turns 3,831 stream observations, and the rain before each of them, into a probability that water is flowing, for streams it has never seen and then for fountains. The table is small, the classes are unbalanced (83 % of the observations are flowing), and what matters is whether the probabilities can be trusted, which I measured stream by stream. It did as well as the best simple baseline I tried, with no tuning at all, in one forward pass on a laptop CPU, and the validation told me exactly how far to trust it. Built with PriorLabs-TabPFN.&lt;/p&gt;




&lt;p&gt;How do you deal with water on long rides or hikes, when you don't know the fountains? Do you trust them, carry everything, or ask at the café?&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opendata</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Asked in Dutch, sent to an English menu</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 06 Oct 2026 07:20:45 +0000</pubDate>
      <link>https://dev.to/dsiacci/asked-in-dutch-sent-to-an-english-menu-48m4</link>
      <guid>https://dev.to/dsiacci/asked-in-dutch-sent-to-an-english-menu-48m4</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Dutch reader asks where, in the back office, they set the colors of their app's interface, role by role:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Kunt u me het menupad in de back-office geven waarmee ik het kleurenpalet van de interface per rol instel?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model gets the English edition of the article that answers it. Fourteen models on Kaggle got that exact message, and all fourteen gave the same path, thirteen of them in a Dutch sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Het menupad is &lt;strong&gt;My App &amp;gt; App Style &amp;gt; Colors&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On a Dutch screen, that menu reads &lt;em&gt;Mijn App &amp;gt; App-Stijl &amp;gt; Kleuren&lt;/em&gt;. The fact is right, the language is right, and the menu is the one from another edition. Dutch is close enough here to find your way. A Portuguese reader handed the German edition of another article gets less help: ten of the fourteen models told them, in Portuguese, to choose &lt;em&gt;„Mit KI erstellen"&lt;/em&gt;, the German edition's label.&lt;/p&gt;

&lt;p&gt;I co-founded GoodBarber, an app platform, and I run its engineering. Our blog comes in seven languages, and much of it names a back-office menu or an app's button. A retrieve-then-answer loop over these pages doesn't always return one in the reader's language. I wanted to know what happens to the label then.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2605.27649" rel="noopener noreferrer"&gt;MTM-Bench&lt;/a&gt; already measures how the content's language leaks into replies, and &lt;a href="https://dev.to/exochard/would-you-ship-this-italian-a-benchmark-built-from-real-website-bugs-15nm"&gt;another entry in this challenge&lt;/a&gt; checks Italian UI strings. This one scores the label the reader acts on, against their own edition, with controls where English is right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The models copy the label from the page: 58.8% of 5,266 answers on localized labels named only another edition's label, all but two the page's own. Only 1.9% came back in another language.&lt;/li&gt;
&lt;li&gt;English pages are the worst case: 0.14 on average, against 0.22 to 0.35 for the other page languages.&lt;/li&gt;
&lt;li&gt;GPT-6 Astra (0.689), Claude Opus 5 (0.592), GPT-5.5 (0.499) and the open-weight Qwen3 235B (0.483, $0.10 a run) clearly beat the always-English baseline (0.32). The other ten score 0.286 to 0.405.&lt;/li&gt;
&lt;li&gt;One sentence in the system prompt, asking for the reader's labels, takes two Gemini Flash models from about 0.37 to 0.77 and 0.86, past the best model without it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I Benchmarked
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The material.&lt;/strong&gt; Eleven items from ten articles of our blog, each something a reader acts on: four back-office paths or options, five strings an app shows, and two controls whose English name is right in every edition (&lt;em&gt;AI Extension Builder&lt;/em&gt;, a &lt;em&gt;Lost &amp;amp; Found&lt;/em&gt; board).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;English edition&lt;/th&gt;
&lt;th&gt;French edition&lt;/th&gt;
&lt;th&gt;German edition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Colors by role&lt;/td&gt;
&lt;td&gt;My App &amp;gt; App Style &amp;gt; Colors&lt;/td&gt;
&lt;td&gt;Mon app &amp;gt; Style de l'app &amp;gt; Couleurs&lt;/td&gt;
&lt;td&gt;Meine App &amp;gt; App-Stil &amp;gt; Farben&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start a new game&lt;/td&gt;
&lt;td&gt;Play again&lt;/td&gt;
&lt;td&gt;Rejouer&lt;/td&gt;
&lt;td&gt;Nochmal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Podcast episode already started&lt;/td&gt;
&lt;td&gt;12 min left&lt;/td&gt;
&lt;td&gt;Il reste 12 min&lt;/td&gt;
&lt;td&gt;noch 12 Min.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I checked every back-office label on screenshots of the real back office in the seven languages; the label on the reader's screen also gets full credit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The minimal pair.&lt;/strong&gt; The reader asks in their language, Q. A simulated retrieval step puts two blog sections in the message, in language C: the one that holds the answer and one on a nearby subject. Everything else is fixed; only C changes. Seven reader languages times seven page languages make 49 cells per item. In the seven cells where C equals Q, the page already holds the reader's label: these same-language cells check the setup and aren't scored. That leaves 462 scored cells per model.&lt;/p&gt;

&lt;p&gt;The system prompt says nothing about the language of the answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are the reader help assistant for a software company's blog. Answer the reader's question using only the retrieved documents provided in the message. If the documents do not answer the question, say so. End your answer with the id of the document you relied on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Using only the retrieved documents" bounds the facts, not the wording: the models already write their answer in the reader's language, and a label they translate themselves gets full credit, so nothing beyond the page is needed to score 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scoring.&lt;/strong&gt; No model judges the answers. The scorer is code, with 39 tests, and three gates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The fact is there&lt;/strong&gt;: a label from any edition, or the item's key concepts. Otherwise 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The answer is in the reader's language&lt;/strong&gt;, once quotes, URLs and ids are stripped. Otherwise 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The label&lt;/strong&gt;:&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;What the answer contains&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reader's label&lt;/td&gt;
&lt;td&gt;the reader's label, no other edition's&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Both&lt;/td&gt;
&lt;td&gt;the reader's label and another edition's&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own translation&lt;/td&gt;
&lt;td&gt;its own translation, no other edition's label&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other edition's label&lt;/td&gt;
&lt;td&gt;another edition's label, not the reader's&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control kept&lt;/td&gt;
&lt;td&gt;the English name, on a control&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control translated&lt;/td&gt;
&lt;td&gt;a translated name, on a control&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pasting a label from an edition the reader doesn't see costs points, which is why both labels score half: the reader still has to work out which one is theirs.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Baselines, run through the same scorer
  &lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Baseline&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oracle (the reader's edition, word for word)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The reader's label everywhere, controls translated too&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always answer with the English label&lt;/td&gt;
&lt;td&gt;0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copy the label from the retrieved page&lt;/td&gt;
&lt;td&gt;0.19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vague answer ("go to the settings")&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refusal&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row loses only the controls: translating everything doesn't score 1 either.&lt;/p&gt;



&lt;br&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;Fourteen models, one run each on Kaggle's model proxy, from September 30 to October 2. The lineup is drawn from the roster Kaggle offers for benchmarks, not from what we run in our products: a large and a small model from each vendor, the latest generation where I could, plus open-weight models, to see whether size or vendor changes what happens to the label. Reasoning was set to low, with at most 4,096 output tokens and four attempts per cell. A run that loses more than 10% of its cells to proxy errors posts no score. Three aren't measured: gpt-oss-120b lost too many cells to rate limits in three runs at different hours, and every request to Gemma 4 26B and Grok 4.6 came back with an error (400 and 404).&lt;/p&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Reader's label&lt;/th&gt;
&lt;th&gt;Both&lt;/th&gt;
&lt;th&gt;Own translation&lt;/th&gt;
&lt;th&gt;Other edition's label only&lt;/th&gt;
&lt;th&gt;Wrong language&lt;/th&gt;
&lt;th&gt;Run cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Astra&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.689&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$3.82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5&lt;/td&gt;
&lt;td&gt;0.592&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;0.499&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;192&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;$2.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3 235B&lt;/td&gt;
&lt;td&gt;0.483*&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;0.405&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;222&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;$1.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;0.379&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;205&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;$1.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;0.377&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;218&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$0.44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;0.374&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;132&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;223&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$0.31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Pro&lt;/td&gt;
&lt;td&gt;0.345&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;261&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$3.42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5&lt;/td&gt;
&lt;td&gt;0.337&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;265&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$1.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-R1&lt;/td&gt;
&lt;td&gt;0.330&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;223&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;$1.71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 nano&lt;/td&gt;
&lt;td&gt;0.317&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;278&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;$0.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Flash-Lite&lt;/td&gt;
&lt;td&gt;0.303&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;303&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;$0.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;0.286&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;315&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;$0.45&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The score covers the 462 scored cells; the counts, the 378 cells of the nine localized items. *Qwen3 235B: 431 scored cells, 352 localized.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1a7w2d2c75nzjd1w1322.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1a7w2d2c75nzjd1w1322.png" alt="Score on the localized items by reader language and page language, averaged over fourteen models" width="800" height="894"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The models copy the label from the page.&lt;/strong&gt; On the localized items, 3,096 of the 5,266 answers (58.8%) named only another edition's label, and 3,094 of those named the page's own. Copying is right in two places, and the models get both: on the same-language cells and the controls, every model but DeepSeek-R1 scores at least 0.97. Everywhere else it's wrong, yet the most common outcome. The language itself rarely leaks: 1.9% of answers came back in another one. DeepSeek-R1 has a different problem: it answered in English 58 times across its 539 cells, 10 of them where the page was already in the reader's language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;English pages are the worst case.&lt;/strong&gt; With an English page, the localized items average 0.14, against 0.22 to 0.35 for the other languages, and 78% of answers named only the English label. Even GPT-6 Astra drops to 0.278, from 0.50 or more elsewhere. Asked by a French reader about a game's results screen, with the English page, all fourteen models named &lt;em&gt;Play again&lt;/em&gt;; two added &lt;em&gt;(Rejouer)&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The leaders get there differently.&lt;/strong&gt; GPT-6 Astra most often gives the reader's label alone (146 cells) or its own translation (43). Claude Opus 5 most often gives both (193), which scores half. Tied for third, GPT-5.5 and Qwen3 235B copy nearly as often as Claude Sonnet 5 (51% and 57% of localized cells, against 59%) but commit to one label far more often (108 and 115 cells, against 56).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;12 min left&lt;/em&gt; is the one label the models often translate themselves&lt;/strong&gt;: 29% of its answers, when no other item goes above 5%. It's also the only label with a number in it, a lead from a single item. It scores 0.592, &lt;em&gt;Play again&lt;/em&gt; 0.077.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One sentence in the prompt changes most of it.&lt;/strong&gt; I reran the grid on three models with one more sentence in the system prompt: &lt;em&gt;"Name menus, buttons and other interface labels as the reader's own screen shows them, in the reader's language, not as the documents write them."&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Without&lt;/th&gt;
&lt;th&gt;With&lt;/th&gt;
&lt;th&gt;Other edition's label only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;0.377&lt;/td&gt;
&lt;td&gt;0.855&lt;/td&gt;
&lt;td&gt;218 → 37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;0.374&lt;/td&gt;
&lt;td&gt;0.768&lt;/td&gt;
&lt;td&gt;223 → 63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 nano&lt;/td&gt;
&lt;td&gt;0.317&lt;/td&gt;
&lt;td&gt;0.405&lt;/td&gt;
&lt;td&gt;278 → 235&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With it, both Flash models beat GPT-6 Astra without it. Nano gains much less, and 3.8 Flash translated 4 of the 84 controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kaggle task: &lt;a href="https://www.kaggle.com/benchmarks/tasks/dumenicu/wrong-screen-answers" rel="noopener noreferrer"&gt;wrong-screen-answers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Dataset: &lt;a href="https://www.kaggle.com/datasets/dumenicu/wrong-screen-answers-data" rel="noopener noreferrer"&gt;wrong-screen-answers-data&lt;/a&gt; (frozen sections, questions, labels, scorer and prompt builder; blog text © GoodBarber, reproduced with permission for evaluation)&lt;/li&gt;
&lt;li&gt;Instruction variant: &lt;a href="https://www.kaggle.com/benchmarks/tasks/dumenicu/wrong-screen-answers-labels" rel="noopener noreferrer"&gt;wrong-screen-answers-labels&lt;/a&gt; (same grid, one more sentence in the system prompt)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The task builds every prompt from the dataset, scorer included, so a new model gets the exact bytes these did.&lt;/p&gt;

&lt;p&gt;What it doesn't measure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run-to-run variation.&lt;/strong&gt; A second run moved Gemini 3.7 Flash by 0.004 and GPT-5.4 nano by 0.010, so gaps of a few hundredths are ties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The instruction on bigger models.&lt;/strong&gt; It ran on three small ones; both of Qwen3 235B's runs lost too many cells to rate limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scorer's blind spots.&lt;/strong&gt; A glossed &lt;em&gt;« encore 12 min » / « nog 12 min »&lt;/em&gt; counts as a leak; an invented hybrid, &lt;em&gt;"Com KI criar"&lt;/em&gt;, as an own translation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retrieval.&lt;/strong&gt; The sections are chosen in advance, the answer comes first, with neutral ids. This measures what a model does with the context it's given, not a retrieval stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I take from it.&lt;/strong&gt; The label in the answer comes from the page, not from the reader, and the best model still gives only the page's label in a quarter of its answers. So in a retrieve-then-answer loop over a multilingual corpus, the first thing I'd check is which edition comes back, before choosing which model reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this was made
&lt;/h2&gt;

&lt;p&gt;I built this with Claude Code over two days: harness, scorer, tests, Kaggle task and analysis. I chose the angle and reviewed the questions. The questions were drafted with Claude and back-translated by GPT-6 Astra, from another family, since Claude models are in the lineup.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>I built my friend a control tower that talks back</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Sat, 03 Oct 2026 16:00:54 +0000</pubDate>
      <link>https://dev.to/dsiacci/i-built-my-friend-a-control-tower-that-talks-back-5f1</link>
      <guid>https://dev.to/dsiacci/i-built-my-friend-a-control-tower-that-talks-back-5f1</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The tower says: "Foxtrot Alpha Bravo Charlie Delta, Isola Ground, runway two seven, QNH one zero one three, taxi holding point Alpha One via Bravo." You hold the space bar and read it back, and you say one zero zero three. The tower comes straight back: "Foxtrot Alpha Bravo Charlie Delta, negative, QNH one zero one three."&lt;/p&gt;

&lt;p&gt;That tower is a synthetic voice running on a laptop, the aerodrome doesn't exist, and the whole thing is a small game I built this weekend for a friend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My friend just got his private pilot's licence. To fly abroad he needs an English language proficiency endorsement on it, and he's wondering whether to go for it, but he's shy about speaking English on the radio. I passed mine two years ago, which doesn't make my English good enough to coach anyone: I am just a private pilot myself. The plan is simple: when he has it, we fly abroad together.&lt;/p&gt;

&lt;p&gt;He knows the procedures. What he hasn't done much is say them in English, out loud, in the right order, with the numbers right, while someone waits for the answer. You can read the phraseology manual on the sofa. You can't rehearse the radio alone, because nobody talks back. This won't prepare him for the proficiency check; it gives him somewhere to say the words, alone, as many times as he wants, before he says them to someone.&lt;/p&gt;

&lt;p&gt;So I built him something that talks back: &lt;strong&gt;phraseology&lt;/strong&gt;, a practice game for one traffic circuit at Isola, a made-up controlled aerodrome. You're F-ABCD at the flying club. You call Ground, taxi to the holding point, change to Tower, line up, take off, fly a touch and go, then a second circuit to land, vacate and taxi back. Runway, wind, QNH, squawk and the other traffic change every game, and up to three surprises can change the plan: "number 2, follow the Cherokee on base", "extend downwind", etc. A second aircraft shares the frequency, with its own voice.&lt;/p&gt;

&lt;p&gt;You answer with the space bar as your press-to-talk. After each transmission the page tells you what was missing or wrong, in plain words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"QNH: you said QNH 1003, the tower said QNH 1013."&lt;/li&gt;
&lt;li&gt;"Missing: runway 27 (the runway in use is always read back)."&lt;/li&gt;
&lt;li&gt;"Put your callsign at the end of a readback."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no score and no level. If the readback is wrong, the tower says "negative" and gives the correct part, and you read it back again.&lt;/p&gt;

&lt;p&gt;It's a practice game, not training. It isn't an approved training device or an assessment, and it doesn't prepare anyone for a language proficiency check. The aerodrome, frequencies and traffic are invented, and no real ATC recording is used. Phraseology also differs from one country to another. Your instructor and your country's official manuals are the reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/zYbAqy02LJc" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The video is one real game, played on a small demo server and recorded in a headless browser: speech recognition, the checker and the tower are the real ones. The pilot is synthetic too, Kokoro's French voice reading English, so you hear the whole loop with an accent (I have to say I am too shy myself to publish a video with my own voice in this context!). The waits while the server transcribes are cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/dsiacci" rel="noopener noreferrer"&gt;
        dsiacci
      &lt;/a&gt; / &lt;a href="https://github.com/dsiacci/phraseology" rel="noopener noreferrer"&gt;
        phraseology
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;phraseology&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Practice the radio calls of a traffic circuit in English, out loud, on your own laptop.&lt;/p&gt;
&lt;p&gt;The tower talks to you with a synthetic voice. You hold the space bar like a press-to-talk switch and answer. An open-weight speech model transcribes you on your machine, a set of plain rules checks your readback, and the circuit moves on. If the readback is wrong, the tower answers "negative" and gives the correct version, and you read it back again.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A practice game, not training.&lt;/strong&gt; This is not an approved training device, not an assessment, and not a preparation for any language proficiency check. The aerodrome, its frequencies and the traffic are made up. Phraseology differs from one country to the next: your flight instructor and your national authority's manual are the reference.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/dsiacci/phraseology/docs/screenshot.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdsiacci%2Fphraseology%2FHEAD%2Fdocs%2Fscreenshot.png" alt="A game in progress: a wrong QNH, the tower's &amp;quot;negative&amp;quot;, the corrected readback, then the next instruction"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What a game looks like&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;You are F-ABCD, a light aircraft parked at the flying club of Isola…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/dsiacci/phraseology" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Python with no web framework, GPL-3.0-or-later. &lt;code&gt;pip install .&lt;/code&gt;, &lt;code&gt;python -m phraseology download&lt;/code&gt; once, then &lt;code&gt;python -m phraseology serve --open&lt;/code&gt;. Any recent laptop works, no GPU. Add &lt;code&gt;?seed=71&lt;/code&gt; to the page's address to play the game from the video.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The loop is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;microphone → browser (16 kHz WAV) → faster-whisper, on the CPU
          → normalizer → readback rules ← scenario (state machine)
          → next tower message → Piper → speaker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;No language model decides anything.&lt;/strong&gt; Phraseology is meant to be said word for word, so the moment the tower speaks, the game knows exactly what a correct readback contains: the runway, the QNH, the holding point, the callsign at the end. Checking it is a matter of rules. The open models hear and speak; the rules decide. That also means every message the tower says comes from templates I wrote with made-up values, never from generated text.&lt;/p&gt;

&lt;p&gt;The rules are the ones a pilot learns, in my own words. The runway in use, clearances on a runway, altimeter settings, transponder codes and new frequencies are always read back. Other instructions can be acknowledged ("wilco"), though the game asks for taxi instructions in full, as the UK manual does. A readback ends with your callsign, a new call starts with it, and you only shorten F-ABCD to F-CD after the station has.&lt;/p&gt;

&lt;p&gt;Most of the work went into the space between what you say and what Whisper writes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The prompt must not contain the values.&lt;/strong&gt; faster-whisper takes an initial prompt, and giving it the station names, the callsign and the radio words helps a lot. Giving it the QNH would be a mistake: the transcript leans towards what's in the prompt, so a wrong readback would come out right. The prompt only holds what never changes during a game.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order matters in that prompt.&lt;/strong&gt; When it ended with "say again", Whisper dropped a "say again" spoken at the start of the clip, as if it had already written it. The callsign now goes last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whisper has opinions about "niner".&lt;/strong&gt; The base.en model wrote "niner niner eight" as "9 and 9 are 8", or "9R9R8", or turned "zero niner" into "Zero Minor". The bigger small.en heard "nine-oh" and wrote 9098. "Four" came out as "for", "via" as "wire". A normalizer undoes these slips, but only where a slip can't change a value: "for" becomes 4 between digits or after "squawk", and stays "for" in "cleared for take-off"; a zero after a nine is dropped only when the QNH would otherwise read above 1100 hPa, which no altimeter setting does. Then values are compared exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mishearings have to fail safe.&lt;/strong&gt; The tests close the loop with the real models: Piper speaks correct and wrong readbacks (wrong QNH, wrong squawk, wrong frequency, "cleared to land" instead of "cleared for take-off", wrong callsign, "roger" instead of a readback), Whisper transcribes them, and the checker has to get the verdict right. With small.en, the model the demo server ran, and two synthetic voices, 14 of 14 wrong readbacks were rejected and 14 of 16 correct ones were accepted (15 of 16 with the smaller base.en). The misses were correct readbacks misheard ("A.K. Wright" for "vacate right"), so the tower asked again. Across every run, a wrong readback was never accepted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The demo server started on base.en, the smaller model, because it answered faster. The first real test, with a French accent, broke it: "at the flying club, request taxi" came out as "de France, clamps, stochasticity". On readbacks spoken with a strong French accent, small.en got 5 of 6 right and base.en 3 of 6, at 4.2 seconds per readback on two vCPUs instead of 1.4. I took the slower one: on a practice tool, a readback that is right but rejected is worse than a wait.&lt;/p&gt;

&lt;p&gt;I built it over the weekend with an AI coding agent.&lt;/p&gt;

&lt;p&gt;Credits, because none of this works without them: &lt;a href="https://github.com/openai/whisper" rel="noopener noreferrer"&gt;Whisper&lt;/a&gt; by OpenAI (MIT, open weights), &lt;a href="https://github.com/SYSTRAN/faster-whisper" rel="noopener noreferrer"&gt;faster-whisper&lt;/a&gt; and &lt;a href="https://github.com/OpenNMT/CTranslate2" rel="noopener noreferrer"&gt;CTranslate2&lt;/a&gt; (MIT), the Silero VAD model (MIT), &lt;a href="https://github.com/OHF-Voice/piper1-gpl" rel="noopener noreferrer"&gt;Piper&lt;/a&gt; with espeak-ng (GPL-3.0-or-later) on ONNX Runtime (MIT), and two voices from rhasspy/piper-voices, en_US-norman for the tower and en_GB-cori for the other aircraft, both trained from scratch on public-domain LibriVox recordings; the video's pilot is &lt;a href="https://huggingface.co/hexgrad/Kokoro-82M" rel="noopener noreferrer"&gt;Kokoro-82M&lt;/a&gt; (Apache 2.0), French voice ff_siwis (SIWIS database, CC BY 4.0).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Practicing a language means sounding unsure, repeating yourself, getting numbers wrong. That recording belongs on my friend's laptop. With open weights, Whisper runs there, on the CPU, and the voice never leaves the machine. After the first download it works with no internet, so it works at the airfield, and it costs nothing to run.&lt;/p&gt;

&lt;p&gt;Having the model on my side of the wire also let me do the work above. I could hand Whisper a prompt, measure what that prompt did to a wrong readback, and decide what goes in it. I could pin the version, decode in int8 on a CPU, set the beam size and the voice-activity filter, and run a test suite against the real model as often as I liked.&lt;/p&gt;

&lt;p&gt;The voices too: Piper's voices come with model cards, and I could read where each one was trained. Two that I first planned to use turned out to be fine-tuned from a voice whose dataset license doesn't fit this project, so I picked ones trained from scratch on public-domain recordings. And because Piper is GPL, so is this game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of DigitalOcean.&lt;/strong&gt; The game in the video was served by a DigitalOcean Droplet with 2 vCPUs, 4 GB of memory and no GPU. Whisper small.en ran on the Droplet's CPU and Piper spoke the tower, as a systemd service behind Caddy, which got the HTTPS certificate on its own (the browser only opens the microphone over HTTPS). The Droplet only let in SSH, HTTP and HTTPS, transcribed one recording at a time with a short queue, capped recordings at 10 seconds, rate-limited each address, and never wrote audio to disk. The setup is two scripts in the repository, so anyone can run their own.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>python</category>
    </item>
    <item>
      <title>"Push, PR, merge." Two minutes later: "Stop. I'm not opening a PR to myself."</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Thu, 01 Oct 2026 07:50:18 +0000</pubDate>
      <link>https://dev.to/dsiacci/push-pr-merge-two-minutes-later-stop-im-not-opening-a-pr-to-myself-33ld</link>
      <guid>https://dev.to/dsiacci/push-pr-merge-two-minutes-later-stop-im-not-opening-a-pr-to-myself-33ld</guid>
      <description>&lt;p&gt;On September 9 at 07:21 I typed four words to an agent: push, PR, merge. At 07:23 I interrupted it: stop, just push to master, I'm not going to open a pull request to myself. The agent pushed three commits, fast-forward, and wrote the rule down so the next session would know it. In between, it had done exactly what the tooling around it is built to do, and that's the part worth looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change
&lt;/h2&gt;

&lt;p&gt;The work was small. An internal tool keeps a catalog of entries that we generate once, review, and sometimes touch up by hand in production. The agent had generated a new batch for the entries that had none, and I wanted it in production.&lt;/p&gt;

&lt;p&gt;At 07:08 I asked the question that mattered: the import updates existing entries in place, some of them were edited by hand in production, so what exactly will this overwrite? The agent went and checked. Those edits are saved in place, under the same identifier, so a full import would have replayed our local versions over the production ones and reset their review status. Silently.&lt;/p&gt;

&lt;p&gt;The next ten minutes went like this. An import option that only creates entries that don't exist yet. Sixteen tests, two of them pinning that exact danger. A run against a copy of production, rolled back. A second door found on the way: the import button in the review screen called the same function without the guard, so it got the same fix. And a read-only preflight script that tells you, against production, what the import would do before it does anything.&lt;/p&gt;

&lt;p&gt;That was the review. It happened in the session, before the commit, and it started with one question from the person who knew the hand edits existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the tooling took over
&lt;/h2&gt;

&lt;p&gt;At 07:15 I said I'd pull master on the server and run the two commands. The agent pointed out that the branch had never been pushed. At 07:21 I wrote "push + PR + merge into master", because that's the sentence twenty years of workflow put in your fingers.&lt;/p&gt;

&lt;p&gt;The agent took the words literally, and the instruction file of that repository routes "push" and "create a PR" to a shipping skill. So it started a release: health checks, the test suite, the linter, a look for a version file to bump and a changelog to update, and the discovery that it could push to our Git host but had no credentials to open the pull request there. Two and a half minutes of ceremony for three commits I had already reviewed with the agent, line by line, ten minutes earlier.&lt;/p&gt;

&lt;p&gt;That's when I stopped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a pull request is for
&lt;/h2&gt;

&lt;p&gt;A pull request is a conversation between two people about a change one of them made. The author explains, the reviewer asks, and the merge records that someone other than the author looked. It's also a lock: nothing lands while the conversation is open.&lt;/p&gt;

&lt;p&gt;With one human and several agents, none of that holds. The author is an agent, the reviewer is me, and the review already happened, in the session, while the change was being made, because that's when I can ask "what will this overwrite?" and get an answer in minutes. A PR opened after that is me approving my own review. It adds a merge commit, a CI run, and a page nobody reads.&lt;/p&gt;

&lt;p&gt;Mathieu Poli, who runs frontend engineering with us, wrote &lt;a href="https://dev.to/goodbarber/our-tools-are-built-for-humans-not-for-agents-5f34"&gt;the team version of this&lt;/a&gt;: pull requests do work with agents, and that's exactly the trap. On a repository where I'm alone with my agents, the trap is just easier to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaced it
&lt;/h2&gt;

&lt;p&gt;On that repository, the rule born that morning is short, and the agent wrote it down itself at 07:25: when I say push, it pushes to master, fast-forward, and nothing else. No pull request, no version bump, no changelog. I'm the only maintainer there, and by the time I say push, the review has already happened.&lt;/p&gt;

&lt;p&gt;On the repository where most of my agents work every day, the same idea had already gone further. Nothing but main is ever pushed. Agents work in local worktrees and land their work on main under a shared lock, rebased or fast-forwarded, then push right away. Every session pulls before writing, because several of them run at the same time on different machines. Commits are small and frequent. A rejected push is normal: another session pushed first, you rebase and push again, never force.&lt;/p&gt;

&lt;p&gt;There, the lock a PR gives you comes from pulling before writing and from the remote refusing anything that isn't a fast-forward. The review comes from the question asked in the session, before the commit, by the person who knows what's in production. The trace comes from commit messages that say why, not what.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still true
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The reflex was mine. The agent did what I asked, in the words I used.&lt;/li&gt;
&lt;li&gt;The stricter rule holds because a rejected push is cheap. On September 11, on the second repository, a session's push was rejected because another session had pushed while it was writing. Rebase, push, done. Without that, "everything on main" would be a race.&lt;/li&gt;
&lt;li&gt;Sixteen old agent branches are still sitting on the first repository's remote. The second one has none left.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run several agents on one repository: where does review happen for you, in the pull request or before the commit? I'll answer in the comments with how our sessions are set up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>git</category>
      <category>devops</category>
    </item>
    <item>
      <title>Agents won't kill apps... on a phone, they can't act without them</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:08:15 +0000</pubDate>
      <link>https://dev.to/goodbarber/agents-wont-kill-apps-on-a-phone-they-cant-act-without-them-58h6</link>
      <guid>https://dev.to/goodbarber/agents-wont-kill-apps-on-a-phone-they-cant-act-without-them-58h6</guid>
      <description>&lt;p&gt;At WWDC this year, in the keynote, Apple confirmed something I had been holding as a logical intuition more than as an open question: when the assistant that lives in your phone wants to do something for you, it goes into the apps installed on that phone. An app declares the actions it can perform, and that is what Siri calls. On Android, Google is building the same door under the name AppFunctions, and describes it in its own documentation as the on-device equivalent of the tools an agent calls over MCP.&lt;/p&gt;

&lt;p&gt;Two operating systems, the same year, the same decision. Meanwhile what you keep reading in 2026 is that apps are on their way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'm standing
&lt;/h2&gt;

&lt;p&gt;I am a co-founder of GoodBarber, a platform that builds and publishes mobile apps, where I run engineering and a little bit of everything. So a piece by me arguing that apps have a future deserves the suspicion you'd give a baker writing about bread. Read it that way. Further down I list what would prove me wrong, and I'll come back with numbers when we have them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case against apps, taken seriously
&lt;/h2&gt;

&lt;p&gt;Carl Pei, the CEO of Nothing, &lt;a href="https://techcrunch.com/2026/03/18/nothing-ceo-carl-pei-says-smartphone-apps-will-disappear-as-ai-agents-take-their-place/" rel="noopener noreferrer"&gt;said it plainly at SXSW this spring&lt;/a&gt;: apps are going to disappear. The argument is coherent. An agent doesn't need a screen to order a pizza; it needs an endpoint. If it can reach your service through an API or a website, the app in between is a detour, and a company's real product becomes the surface its agents can call.&lt;/p&gt;

&lt;p&gt;I believe half of it. Agents do remove navigation: nobody wants to tap through four screens when a sentence does the job. A clean, documented surface matters more than it ever did. And we run one ourselves: &lt;a href="https://dev.to/pierrelaurentmedori/building-a-production-mcp-server-how-we-made-goodbarber-agent-ready-without-the-glue-code-4co6"&gt;a production MCP server&lt;/a&gt; that agents call all day to manage apps, without opening a single one of them.&lt;/p&gt;

&lt;p&gt;The disagreement is about where the agent goes when the user is holding a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I think, and I'll own it
&lt;/h2&gt;

&lt;p&gt;Apple clearly wants everything the assistant in your phone does, in what they now call your intelligent personal hub, to go through the apps installed on it. And I think they're right. It's the most logical channel there is.&lt;/p&gt;

&lt;p&gt;Not because apps are nice, or because I sell them. Because on a phone, the app is the one place where three things already exist together: an identity, a set of rights, and a relationship. Everything an agent would have to rebuild from scratch on the web is already sitting there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The door
&lt;/h2&gt;

&lt;p&gt;On a phone, the assistant doesn't browse. It calls what an app has declared. The shape is familiar if you've written tools for an agent: a name, typed parameters, a description the model reads, a result it can show or speak. The difference is that the tool lives inside an installed app, with the app's data and the app's rights, and the operating system is the one dispatching the call.&lt;/p&gt;

&lt;p&gt;If your service isn't installed, it isn't a tool. The assistant can't find it, can't ask it anything, can't act through it. That is a hard line, and both platforms drew it on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rights
&lt;/h2&gt;

&lt;p&gt;Think about what an agent needs before it can do anything useful for a person: who they are, what they've paid for, what they've allowed, how to reach them. On the web, every one of those is a login, a consent screen, a token, a form. In an installed app, all of it already exists. The session is open. The subscription is active. The notification permission was granted months ago. The agent inherits all of it the moment it goes through the app, and it can't do more than the user could, because &lt;a href="https://dev.to/goodbarber/your-chatbot-is-a-second-door-onto-your-content-535h"&gt;an interface inherits the rights of whoever it acts for&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is why I don't think the app's role shrinks as agents get better. An agent that can act on your behalf is worth exactly as much as the rights it can act with, and those rights live in apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interface
&lt;/h2&gt;

&lt;p&gt;Here is where I go a step further. I think the assistant is going to be the natural way to use an app, on the phone. You say what you want; the assistant asks the app; the app answers, in the assistant, with a card, a list, a confirmation. The screens stay for what a screen does better than a sentence, comparing, browsing, checking. I wrote about &lt;a href="https://dev.to/goodbarber/the-first-interface-you-dont-have-to-learn-1f9b"&gt;an agent as the first interface you don't have to learn&lt;/a&gt; from the operator's side; this is the same idea reaching the person who uses the app.&lt;/p&gt;

&lt;p&gt;And I don't think it stops at Siri. An assistant that belongs to the app itself, that answers from its content and acts within its rights, is a second way in, next to the screens, not instead of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two doors, one rule
&lt;/h2&gt;

&lt;p&gt;From where we sit, an app now has two doors for agents, and they don't lead to the same room. Its owner's agent talks to the server that manages the app: content, products, notifications, the things an owner does. The phone's assistant talks to the installed app itself: the things a user does. Two doors, two sides of the product, and one rule that holds on both: each door inherits the rights of whoever it acts for, the owner on one side, the user on the other.&lt;/p&gt;

&lt;p&gt;Our engine already answers one question from Siri, "what's new", and it is &lt;a href="https://www.goodbarber.com/blog/siri-actions-the-mcp-of-your-ios-app-a1616/" rel="noopener noreferrer"&gt;reaching apps in stages&lt;/a&gt;. The real work is ahead, and it has a shape of its own: we don't write one app, we publish thousands from the same engine, each described by its own configuration. So what an assistant can do in an app is something we define the way we define a feature: once, in the engine, as an opening, and every app that has the feature gets it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would prove me wrong
&lt;/h2&gt;

&lt;p&gt;I want this list to be checkable, so here it is.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The assistants route around the apps.&lt;/strong&gt; If in two years Siri and Gemini reach most services through the web or through APIs without an installed app, and the platforms let them, the door I'm describing was a phase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody installs.&lt;/strong&gt; If assistants don't bring people to install anything, and the only apps worth calling are the ten everyone already has, this thesis holds for the giants and for nobody else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The door stays half-open.&lt;/strong&gt; If third-party actions stay a demo feature, reserved for a few partners, the mechanism exists and doesn't matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you ship an app: what did you expose to Siri or Gemini first for this release, and what did you decide to keep behind a screen?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>mcp</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Two weeks of serving Markdown to agents, straight from the nginx logs</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:45:49 +0000</pubDate>
      <link>https://dev.to/dsiacci/two-weeks-of-serving-markdown-to-agents-straight-from-the-nginx-logs-16om</link>
      <guid>https://dev.to/dsiacci/two-weeks-of-serving-markdown-to-agents-straight-from-the-nginx-logs-16om</guid>
      <description>&lt;p&gt;Since early September, every public page of our website has had a Markdown twin, reachable by header or by URL. Two dedicated nginx logs have recorded every request through either door since September 4. Two weeks later I read them, all of them, with the method below. &lt;a href="https://www.goodbarber.com/blog/do-ai-agents-read-markdown-what-two-weeks-of-our-own-server-logs-say-a1622/" rel="noopener noreferrer"&gt;The results, in short, are on our blog&lt;/a&gt;. This is the engineering cut: how the two doors are built, how the count was made, and what the logs can and cannot prove.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this comes from
&lt;/h2&gt;

&lt;p&gt;I co-founded GoodBarber, an app platform, and I run its engineering. The site in question is the one that sells it: marketing pages, a blog, a help center, on eleven language hosts. Pierre-Laurent, our head of backend engineering, counted in August &lt;a href="https://dev.to/pierrelaurentmedori/llmstxt-in-the-wild-1321-requests-and-not-one-ai-assistant-came-looking-3205"&gt;four months of requests to our llms.txt&lt;/a&gt; and found no assistant that came on its own. What stayed with me was his other number: over the same four months, assistants and their crawlers made 1.6 million requests to our regular pages, and Claude Code accounted for about 4,500 of them, half on the help center. The index goes unread while the pages get fetched, so what I wanted to know is what a fetch receives.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the twin is served
&lt;/h2&gt;

&lt;p&gt;The site is a Django application behind nginx, with a full-page cache. The constraint was mine: no rewriting of templates, no duplicated content. So the Markdown is produced from the HTML the full-page cache already holds. The converting middleware is the last one in Django's list, which makes it the first to see a response on the way out, before minification and before the CSRF placeholder is substituted; that's what keeps the output identical between a cache hit and a miss, and cacheable by a fingerprint of the HTML. Conversion happens once per distinct HTML, and everything else is a lookup.&lt;/p&gt;

&lt;p&gt;Two doors open onto it. Send &lt;code&gt;Accept: text/markdown&lt;/code&gt; on any public page and, if the q-values make Markdown the preferred type, you get it at the same URL, with &lt;code&gt;Vary: Accept&lt;/code&gt;. Or add &lt;code&gt;.md&lt;/code&gt; to the path: &lt;code&gt;/pricing.md&lt;/code&gt;, &lt;code&gt;/blog/&amp;lt;slug&amp;gt;.md&lt;/code&gt;, &lt;code&gt;/index.md&lt;/code&gt; for the root. The &lt;code&gt;.md&lt;/code&gt; route resolves the canonical path and hands the request to the same middleware, so the two doors can never disagree. Every eligible HTML page declares its twin twice, in a &lt;code&gt;&amp;lt;link rel="alternate" type="text/markdown"&amp;gt;&lt;/code&gt; and in a &lt;code&gt;Link&lt;/code&gt; header. The twin carries a YAML front matter (title, description, canonical URL, dates taken from the page's JSON-LD), a &lt;code&gt;Link&lt;/code&gt; back to the canonical page, and &lt;code&gt;X-Robots-Tag: noindex&lt;/code&gt;. An unknown &lt;code&gt;.md&lt;/code&gt; path gets a short Markdown 404 document, not a converted error page. Twenty-four back-office and partial routes are excluded and answer 404 under &lt;code&gt;.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before it went live, we fetched the same three pages with the feature on and off, as seven kinds of client, and compared the twenty-one responses byte for byte: identical. Browsers get the bytes they got before, for eight microseconds more per request. The same conversion now feeds llms.txt and llms-full.txt, regenerated every night in eleven languages by rendering the pages in-process, with the currency forced per country so that the prices in the Markdown match the prices on the page.&lt;/p&gt;

&lt;p&gt;None of this is original. &lt;a href="https://blog.cloudflare.com/markdown-for-agents/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt; has done the conversion at the edge since February. &lt;a href="https://zapier.com/pricing.md" rel="noopener noreferrer"&gt;Zapier&lt;/a&gt; and &lt;a href="https://vercel.com/docs/agent-resources/markdown-access" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt; serve both doors, with front matter and the alternate link. We followed a pattern that already existed, on a site that isn't documentation, and counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;One measurement framed my expectations before I opened the logs. &lt;a href="https://www.checklyhq.com/blog/state-of-ai-agent-content-negotation/" rel="noopener noreferrer"&gt;Checkly measured&lt;/a&gt; in February which coding agents send the header: Claude Code, Cursor and OpenCode do; Codex, Gemini CLI, Copilot and Windsurf don't. The &lt;code&gt;.md&lt;/code&gt; URL is there for the ones that don't.&lt;/p&gt;

&lt;p&gt;When a Next.js middleware started detecting GPTBot by user agent to serve it Markdown instead of HTML, John Mueller called the idea stupid, and &lt;a href="https://www.searchenginejournal.com/cloudflares-new-markdown-for-ai-bots-what-you-need-to-know/567339/" rel="noopener noreferrer"&gt;Search Engine Journal noted&lt;/a&gt; that his objection was to user-agent sniffing, not to content negotiation. We never look at the user agent to decide what to send, and the Markdown is produced from the same HTML the cache already holds, keyed by its fingerprint, so it can't drift from the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I counted
&lt;/h2&gt;

&lt;p&gt;The count uses three sources, all converted to UTC (the logs are in Paris time):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Carries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dedicated nginx log for &lt;code&gt;.md&lt;/code&gt; URLs, on the app servers&lt;/td&gt;
&lt;td&gt;Sep 4 → Sep 18&lt;/td&gt;
&lt;td&gt;about 82,000&lt;/td&gt;
&lt;td&gt;host, &lt;code&gt;Accept&lt;/code&gt;, &lt;code&gt;X-Forwarded-For&lt;/code&gt;, &lt;code&gt;X-Real-IP&lt;/code&gt;, scheme, response time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dedicated nginx log for requests whose &lt;code&gt;Accept&lt;/code&gt; contains &lt;code&gt;text/markdown&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Sep 4 → Sep 18&lt;/td&gt;
&lt;td&gt;about 23,000&lt;/td&gt;
&lt;td&gt;same format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;front-end syslog for all traffic&lt;/td&gt;
&lt;td&gt;May → Sep 18&lt;/td&gt;
&lt;td&gt;about 260 million&lt;/td&gt;
&lt;td&gt;host and &lt;code&gt;X-Forwarded-For&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A request is a log line. A visitor is a triple of IP, user agent and UTC day. Every "who" is a user-agent string checked against the IP ranges its operator publishes, downloaded once and dated because some of them rotate daily: OpenAI's three lists, Perplexity's two, Google's four, Bing, Apple, DuckDuckGo, Common Crawl, Mistral, Ahrefs, Amazon's page, and Meta's AS routes. Baidu, Yandex and PetalBot document reverse DNS instead, so their hits were checked by forward-confirmed reverse lookup. Anthropic publishes nothing: a ClaudeBot request can only be matched against the blocks that carry the rest of ClaudeBot's HTML traffic in the same logs, which is an indication, not a proof, and I treat it as declared.&lt;/p&gt;

&lt;p&gt;Our own traffic is excluded: our test tools and eight internal addresses, every request from them whatever the user agent. For a negotiated request, "received Markdown" is established by comparing the response size with the &lt;code&gt;.md&lt;/code&gt; twin of the same path. The counts below come from the two dedicated logs; the front-end log serves for the period before September 4 and for referers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The count
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;.md&lt;/code&gt; door: about 80,000 requests, almost all GET, from about 5,800 addresses and 20,000 visitors, on about 26,000 distinct host-and-path pairs, almost all of them served at least once with a 200. No 5xx in two weeks.&lt;/p&gt;

&lt;p&gt;The header door: about 22,500 requests, two thirds GET and one third HEAD, from about 5,700 addresses and 6,700 visitors. Only about 11,900 were served with a 200. About 10,500 were redirects, and some 8,500 of those were the same thing: a request for a page without its trailing slash, which our router answers with a 301 before the middleware ever runs. ShapBot asks for every page that way, HEAD first, then GET, and collects most of the redirects. The negotiated 404s, about 130, got the Markdown 404 document.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Door&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Served 200&lt;/th&gt;
&lt;th&gt;Who, mostly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.md&lt;/code&gt; URL&lt;/td&gt;
&lt;td&gt;about 80,000&lt;/td&gt;
&lt;td&gt;93.8%&lt;/td&gt;
&lt;td&gt;crawlers: 51.0% declared AI crawlers, then Baidu, Bing, Apple&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Accept: text/markdown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 22,500&lt;/td&gt;
&lt;td&gt;52.7%&lt;/td&gt;
&lt;td&gt;answer engines built for agents, and one coding agent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They don't attract the same clients. The &lt;code&gt;.md&lt;/code&gt; link is what corpus crawlers use. Amazonbot, about 13,000 requests, every IP in Amazon's published range, no referer. Meta-ExternalAgent, about 10,000, verified, no referer. GPTBot, about 5,800 verified requests, every one of them with a referer from our own site, almost always the HTML twin, which the same client had fetched in the previous ten minutes in 98.9% of cases. OpenAI's crawlers follow our &lt;code&gt;rel="alternate"&lt;/code&gt;. Bing's crawler came too, about 6,800 verified &lt;code&gt;.md&lt;/code&gt; requests, probably by the same route, though it sends no referer; 91.7% of them target our internal search pages, which answer any query anyone types, so that is where most of Bing's visit went. The real Googlebot asked for zero &lt;code&gt;.md&lt;/code&gt;. Every request that called itself Googlebot came from outside Google's ranges.&lt;/p&gt;

&lt;p&gt;The header is a different population. The big consumer assistants and their crawlers almost never send it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Declared agent&lt;/th&gt;
&lt;th&gt;Page requests seen&lt;/th&gt;
&lt;th&gt;With &lt;code&gt;Accept: text/markdown&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ShapBot&lt;/td&gt;
&lt;td&gt;about 12,000&lt;/td&gt;
&lt;td&gt;98.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ExaSearchBot&lt;/td&gt;
&lt;td&gt;about 4,700&lt;/td&gt;
&lt;td&gt;93.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;about 1,400&lt;/td&gt;
&lt;td&gt;99.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;about 24,000&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT-User&lt;/td&gt;
&lt;td&gt;about 10,000&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;about 7,700&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;about 5,800&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ShapBot and ExaSearchBot, two answer engines built for agents, make 80% of the negotiated requests. Neither publishes IP ranges, so those identities are declared, not verified. The third is the visitor Pierre-Laurent had already counted: about 1,400 requests declaring themselves Claude Code, from more than 500 addresses and 35 versions of the client, sending the header on 99.1% of their page requests and receiving Markdown in 97.7% of the cases we can determine, three quarters of them on the help center. Claude Code runs on a developer's machine, so there is no range to check.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.md&lt;/code&gt; link was the fallback for the assistants that don't send the header. Here is what the assistants that fetch a page because a human asked them a question did with it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent (declared)&lt;/th&gt;
&lt;th&gt;HTML pages fetched&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;.md&lt;/code&gt; or negotiated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT-User&lt;/td&gt;
&lt;td&gt;about 10,000&lt;/td&gt;
&lt;td&gt;1*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude-User (hosted)&lt;/td&gt;
&lt;td&gt;about 2,700&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity-User&lt;/td&gt;
&lt;td&gt;about 900&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* Minutes after the twins went live, from an OpenAI address: that one was me, testing through ChatGPT.&lt;/p&gt;

&lt;p&gt;Verified where a range exists: 89.7% of the ChatGPT-User pages sit in OpenAI's ranges; Anthropic publishes none, so hosted Claude-User can't be checked; none of the Perplexity-User requests fall in Perplexity's four published prefixes, so either the list is stale or the name is borrowed. About fourteen thousand fetches declared as made for a human, one of them on a &lt;code&gt;.md&lt;/code&gt;, and that one mine. Those assistants fetch the HTML and, presumably, convert it on their side; in fourteen days, neither the header nor the &lt;code&gt;.md&lt;/code&gt; link changed that. What they do with the page once they have it, our logs can't see.&lt;/p&gt;

&lt;p&gt;Two smaller things the logs said. Nobody arrived on a &lt;code&gt;.md&lt;/code&gt; from chatgpt.com, perplexity.ai, claude.ai, a search engine or a social network: zero referers in all those categories. And the Markdown weighs 78.7% less than the HTML page it comes from, median over about 22,000 pages with compression included when the client accepts it, 83.9% on the help center. Bytes on the wire; I didn't count tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before September 4
&lt;/h2&gt;

&lt;p&gt;I went back to May in the front-end log to see what asked for Markdown before we served any. In three and a half months, about 900 requests for &lt;code&gt;.md&lt;/code&gt; paths on the site, none of them served: 404s and redirects. &lt;code&gt;README.md&lt;/code&gt;, &lt;code&gt;agents.md&lt;/code&gt;, &lt;code&gt;SKILL.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;CHANGELOG.md&lt;/code&gt;, probed at the root of a marketing site by tools that expect a repository. And in early August, a connector generator guessed the &lt;code&gt;.md&lt;/code&gt; URLs of eleven of our pages, the exact pattern we shipped a month later, and got 404s for all of them.&lt;/p&gt;

&lt;p&gt;llms.txt moved too. Since September 4, OpenAI's crawlers reach it from our own pages, about 25 requests with a page of the site as referer, following the link our pages carry to it; in &lt;a href="https://dev.to/pierrelaurentmedori/llmstxt-in-the-wild-1321-requests-and-not-one-ai-assistant-came-looking-3205"&gt;Pierre-Laurent's window&lt;/a&gt;, the only GPTBot hits with a referer came from a third-party directory. His count continues: since early August, about 1,200 requests for llms.txt, about a hundred from a declared AI operator's crawler or fetcher, nine in ten of them verified, one triggered by a human. Same picture as his four months, at two and a half times the rate: directories, scanners, SEO tools and browsers do the reading, AI operators stay a small minority, and the assistants still don't come for a human: one fetch, against his seventeen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we logged
&lt;/h2&gt;

&lt;p&gt;Two &lt;code&gt;access_log&lt;/code&gt; directives per app server, in the combined format plus the host, the &lt;code&gt;Accept&lt;/code&gt; header, &lt;code&gt;X-Forwarded-For&lt;/code&gt;, &lt;code&gt;X-Real-IP&lt;/code&gt;, the origin scheme and the response time: one file for every request whose &lt;code&gt;Accept&lt;/code&gt; contains &lt;code&gt;text/markdown&lt;/code&gt;, one for every &lt;code&gt;.md&lt;/code&gt; path. Everything else comes from the front-end syslog, which carries the host and &lt;code&gt;X-Forwarded-For&lt;/code&gt;. The operators' published IP ranges were downloaded once and dated: one of OpenAI's lists changed on the day I downloaded it, so a verification replayed a month later would not match without that snapshot. The field that would turn the last step, "received Markdown", from a size comparison into a lookup is &lt;code&gt;$sent_http_content_type&lt;/code&gt;, next to the status code.&lt;/p&gt;

&lt;p&gt;The clients to expect are the ones already sending the header, coding agents and the answer engines built for them. The assistants answering humans fetched HTML before the twins existed and still do.&lt;/p&gt;

&lt;p&gt;If you run content negotiation on a site that isn't documentation: what does your access log record for a negotiated response, and how do you keep your own team's curl out of the count? I'll answer in the comments with our numbers.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>seo</category>
      <category>devops</category>
    </item>
    <item>
      <title>The URL on the flyer doesn't know your app changed</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 15 Sep 2026 14:29:50 +0000</pubDate>
      <link>https://dev.to/goodbarber/the-url-on-the-flyer-doesnt-know-your-app-changed-9kn</link>
      <guid>https://dev.to/goodbarber/the-url-on-the-flyer-doesnt-know-your-app-changed-9kn</guid>
      <description>&lt;p&gt;A link leaves your system the moment it's printed. The QR code on a restaurant's counter card, the URL at the bottom of a club's poster, the link a shop put on its packaging three seasons ago — none of these can be recalled. You can't redirect ink. Whoever scans that code has an appointment with your app, and &lt;em&gt;they&lt;/em&gt; chose the date: maybe this afternoon, maybe in four years, long after the app was redesigned twice and the section it pointed to was renamed.&lt;/p&gt;

&lt;p&gt;A link is a promise. And the inconvenient part of the promise is that its duration was never yours to choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  One door, many keys
&lt;/h2&gt;

&lt;p&gt;Links reach an app from more places than anyone plans for: a QR code, a push notification, a URL shared in a chat, a link inside the app's own pages, another app handing over an address. Every one of those arrives shaped differently.&lt;/p&gt;

&lt;p&gt;Our answer is architectural: all of them funnel into a single resolver. Whatever the source, the link is first &lt;a href="https://dev.to/goodbarber/feeding-an-app-from-someone-elses-cms-61l"&gt;translated into the same internal representation&lt;/a&gt; — what is being asked for, in which section — and only then does anything happen. The variety of doors is a parsing problem at the edge; behind the edge there's one door, and one promise to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formats get added. They don't get retired.
&lt;/h2&gt;

&lt;p&gt;Over the years, the shape of our links has changed — of course it has: URLs got cleaner, more readable, more shareable. Each generation was an improvement, and each one created the same obligation: every link emitted in the old shape was already out in the world, printed and pinned and bookmarked.&lt;/p&gt;

&lt;p&gt;So the rule is simple and it only goes one direction: formats get added, never retired. A link built the way we built them years ago still resolves in the app that ships today — not because that old parsing code is anyone's pride, but because the alternative is breaking a promise someone else is still holding. Backward compatibility of this kind isn't a feature you announce; it's debt you carry with a straight face, because the person who scanned yesterday's format doesn't know it's yesterday's, and shouldn't have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Durable by construction
&lt;/h2&gt;

&lt;p&gt;Honoring old links forever would be impossible if a link depended on things that change. So the durability is built into the addressing, not into a policy of never changing anything.&lt;/p&gt;

&lt;p&gt;A link targets a section by a stable identifier — never by its position in a menu. Reorder the navigation, redesign every screen: the identifier doesn't move, so the link doesn't care. Renamed a section's public URL? Where the platform can, the old slug is absorbed by a permanent redirect to the new one. Even the domain gets the benefit of the doubt: a link is matched with and without &lt;code&gt;www&lt;/code&gt;, in &lt;code&gt;http&lt;/code&gt; as well as &lt;code&gt;https&lt;/code&gt; — so a flyer printed back when the web still said &lt;code&gt;http://&lt;/code&gt; opens today's app without noticing what decade it is. None of these tricks is clever on its own; it's the ordinary toolbox of URL hygiene. The only discipline is in refusing to skip any of it, anywhere, ever.&lt;/p&gt;

&lt;p&gt;The pattern behind all of it: &lt;a href="https://dev.to/goodbarber/what-breaks-when-nobody-touches-your-app-for-three-years-2dgm"&gt;everything around an app changes over three years&lt;/a&gt; — the design, the structure, the web's own defaults. The address layer is built so that nothing a link relies on is among the things that changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the promise can't be kept
&lt;/h2&gt;

&lt;p&gt;Sometimes resolution genuinely fails — the section is gone, the content deleted. What happens next depends on where the link came from, and the distinction matters.&lt;/p&gt;

&lt;p&gt;Inside the app, a link that resolves to nothing is a bug, and it gets treated like one: a not-found screen, visible enough that someone fixes it.&lt;/p&gt;

&lt;p&gt;But a link arriving from the outside world — the flyer, the QR in the street — never ends on a dead end. As a last resort, when every attempt at resolution has failed, the visitor lands on the app's home. Not because errors should be hidden, but because of who's standing there: this person didn't hit a bug, they exercised an old promise. Handing them an error page punishes them for our history. Handing them the home screen at least honors the half of the promise that's left — &lt;em&gt;there is an app here, and you're welcome in it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You don't control when a link will be redeemed. You only control whether, on that day, it's honored. Build for the second, because the first was never up to you.&lt;/p&gt;

</description>
      <category>nocode</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The price of a new word</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:30:38 +0000</pubDate>
      <link>https://dev.to/goodbarber/the-price-of-a-new-word-fok</link>
      <guid>https://dev.to/goodbarber/the-price-of-a-new-word-fok</guid>
      <description>&lt;p&gt;An app built on our platform is, at bottom, a configuration — a description read against &lt;a href="https://dev.to/goodbarber/you-can-add-a-word-you-cant-change-what-one-means-3e4h"&gt;an engine that all apps share&lt;/a&gt;. And that configuration is written in a vocabulary the platform defines: the section types. Articles, events, videos, products, forms. A section type is a word in the platform's language.&lt;/p&gt;

&lt;p&gt;This article is about what it costs to add one.&lt;/p&gt;

&lt;p&gt;From the outside, adding a section type looks like building a screen and shipping it. It never is. The difference between a feature and a word is that a feature gets &lt;em&gt;used&lt;/em&gt;, while a word has to be &lt;em&gt;understood&lt;/em&gt; — by everything that reads the language. Until every reader understands it, the word doesn't exist; there's just a screen with a secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everyone who reads the language
&lt;/h2&gt;

&lt;p&gt;Take the events section — on paper, a calendar of what's coming. Here's who has an opinion about it the day it's born.&lt;/p&gt;

&lt;p&gt;Navigation has to know how to route to it and back out of it. The design system has to know how to dress an event — and not in one app's theme, in &lt;em&gt;every&lt;/em&gt; theme any app might wear: an event card has to look deliberate in a minimal text-first design and in a photo-heavy one, because the word belongs to the whole language, not to the app that inspired it. Push has to be able to announce one: a new event should be able to notify subscribers exactly like any other content, without the push system containing a single line specific to calendars. Links have to reach it: an event needs an address that a shared post — or a printed flyer — can carry, and that address has to keep working long after the event itself scrolled off the screen. If the app runs &lt;a href="https://dev.to/goodbarber/your-chatbot-is-a-second-door-onto-your-content-535h"&gt;the chatbot, events become material for answers&lt;/a&gt;: "what's the best show for a first visit?" is now a question the app is expected to handle based on its content. If an agent operates the app, creating and editing an event has to be an operation an agent can perform. And before any of that, the back office has to exist: someone has to &lt;em&gt;write&lt;/em&gt; events — forms, fields, dates that end after they start.&lt;/p&gt;

&lt;p&gt;Each of those is a question the new word has to answer before it's allowed into the language. The calendar screen — the thing that looked like the whole job — is one line on the invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The matrix grows in both directions
&lt;/h2&gt;

&lt;p&gt;Here's the part that compounds: a word added is a word maintained, and the grammar grows both ways.&lt;/p&gt;

&lt;p&gt;Every new word has to be understood by all existing readers — that's the invoice above. But every new &lt;em&gt;reader&lt;/em&gt; has to understand all existing words. The day we plugged a chatbot into apps' content, it couldn't just handle whatever section type was fashionable that year; it had to read the whole language, every word ever added. The day agents arrived, same story: operating an app means operating all of it. The true price of a word was never the feature work of its launch season. It's the row &lt;em&gt;and&lt;/em&gt; the column of that matrix — paid once when the word is born, and again every time the language gains a reader, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  A lighter way to say something new
&lt;/h2&gt;

&lt;p&gt;That price would be unbearable if it were the only option. It isn't — and this is where the economics of a platform get interesting.&lt;/p&gt;

&lt;p&gt;Not every need deserves a word. When one app needs something bespoke, &lt;a href="https://dev.to/goodbarber/we-let-an-ai-write-code-inside-our-no-code-platform-generating-it-was-the-easy-part-1p65"&gt;a section can be described and generated&lt;/a&gt; for that app alone. The generated section lives in a slot the platform already understands: navigation knows how to reach it, every theme knows how to frame it, and nothing anywhere else has to learn a thing. The price stays small for exactly that reason — it teaches nobody anything. Which is also the honest statement of what it doesn't buy: it's a local phrase, not a new word. One app speaks it; the language hasn't changed.&lt;/p&gt;

&lt;p&gt;So there are two prices, and they're honest about what they purchase. The small one is local: the need is served today, for the app that has it. The full one is universal: every subsystem learns the word, and every app on the platform can use it, forever. A bespoke need lives comfortably at the small price. When the same need keeps arriving from different directions, it's telling us it wants to be a word — and then we pay the full price, knowingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invoice is the feature
&lt;/h2&gt;

&lt;p&gt;Because here's what the full price buys. Once the platform has learned a word, it works everywhere the language is spoken: every app can add an events section and inherit — with zero additional work, theirs or ours — the navigation, the theming, the push, the addresses, the answers. An extra screen works where you put it. A word works everywhere, for everyone, from then on.&lt;/p&gt;

&lt;p&gt;That's what "adding a feature" actually means on a platform, and why it bears no resemblance to shipping a screen. The price of a new word is what makes it a word.&lt;/p&gt;

</description>
      <category>nocode</category>
      <category>architecture</category>
      <category>programming</category>
    </item>
    <item>
      <title>The most expensive thing we could sell you is a fork</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Thu, 03 Sep 2026 13:04:19 +0000</pubDate>
      <link>https://dev.to/goodbarber/the-most-expensive-thing-we-could-sell-you-is-a-fork-1161</link>
      <guid>https://dev.to/goodbarber/the-most-expensive-thing-we-could-sell-you-is-a-fork-1161</guid>
      <description>&lt;p&gt;The request arrives regularly, and it's always reasonable: &lt;em&gt;we love the platform — we just need a version of it that's ours.&lt;/em&gt; One extra screen. One different behavior. One exception to a rule that makes sense for everyone else but not for them. From the customer's side, it's the most natural ask in software: they pay, the need is real, and the code already exists. How hard can a copy be?&lt;/p&gt;

&lt;p&gt;Our answer is no. It has been no for as long as the platform has existed, and it will survive any deal size. Not because the need doesn't count — because refusing the fork is an architecture policy, maybe the oldest one we have.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fork is the product minus its future
&lt;/h2&gt;

&lt;p&gt;A fork costs nothing on the day you create it. Version control makes it a keystroke. Every cost comes after, and it compounds.&lt;/p&gt;

&lt;p&gt;A forked engine leaves the inheritance flow. Everything that makes a platform worth paying for — &lt;a href="https://dev.to/goodbarber/what-breaks-when-nobody-touches-your-app-for-three-years-2dgm"&gt;the changes every app absorbs at its next build&lt;/a&gt;: the OS deprecations quietly handled, the new capabilities that just appear — stops reaching it. Someone would have to re-apply each of those changes to the fork, by hand, forever, on a codebase that drifts a little further from the source every month. That someone has better things to do, and eventually doesn't do it.&lt;/p&gt;

&lt;p&gt;So "a version just for us" is not what we'd actually be selling. We'd be selling a version nobody keeps alive — the product, minus its future. The evolution &lt;em&gt;is&lt;/em&gt; the product. A fork is a way of buying the part that was already done and cancelling the part they were paying for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the need gets to live
&lt;/h2&gt;

&lt;p&gt;The need behind the request is real, though, and refusing the fork doesn't make it go away. What's negotiable isn't whether the need gets served — it's &lt;em&gt;where it lives&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The old principle says it in six words: open for extension, closed for modification. The engine is closed; the surface around it is the offer. Custom-code sections, custom widgets, custom navigation — places designed for behavior we never anticipated. And more recently, &lt;a href="https://dev.to/goodbarber/we-let-an-ai-write-code-inside-our-no-code-platform-generating-it-was-the-easy-part-1p65"&gt;a bespoke section can be described and generated&lt;/a&gt; without a line of the engine changing underneath it.&lt;/p&gt;

&lt;p&gt;Extension doesn't even have to mean living outside the engine. Sometimes the specific need becomes capability embedded in every build and expressed only where it's enabled — injected at compile time, dormant everywhere else. That may sound like a fork wearing makeup, but it differs in the one way that matters: the engine stays one. Every build still comes from the same source, still inherits everything, still moves forward with the fleet. Nothing has left the flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the refusal costs us
&lt;/h2&gt;

&lt;p&gt;Here's the part that took longer to understand: a no-fork policy is a discipline for &lt;em&gt;us&lt;/em&gt; before it's a constraint for anyone else. You can only keep refusing forks if the extension points are good enough that the refusal isn't a dead end — and extension points are only good if the engine behind them is built for it.&lt;/p&gt;

&lt;p&gt;Which is where the unglamorous principles earn their keep. Components with one responsibility, that evolve only for that responsibility. Modules that depend on each other as little as possible and talk through interfaces. Functionality grouped with the functionality it belongs with. Written down like that, it reads like a textbook page — until you see these rules as the thing that makes the "no" possible. Every one of them exists so that the engine can stay shared while the needs it serves diverge.&lt;/p&gt;

&lt;p&gt;And the requests themselves feed the discipline. A fork request that no extension point can absorb isn't a customer to talk out of it — it's a spec for the next extension point. I won't claim we convert every refusal that way; we don't. But the ones we did convert are the reason the next request usually finds a place to live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only "just for us" that lasts
&lt;/h2&gt;

&lt;p&gt;What a customer really asks with "a version just for us" is: &lt;em&gt;does my need count?&lt;/em&gt; The answer that holds up over years isn't another platform. It's &lt;a href="https://dev.to/goodbarber/you-can-add-a-word-you-cant-change-what-one-means-3e4h"&gt;another word in the platform&lt;/a&gt; — a capability added to the shared engine, that every app inherits, including theirs.&lt;/p&gt;

&lt;p&gt;Their edge case today, everyone's feature tomorrow. It's a slower yes than a fork, and a less flattering one. It's also the only version of "just for you" that will still be alive in three years.&lt;/p&gt;

</description>
      <category>nocode</category>
      <category>architecture</category>
      <category>saas</category>
    </item>
    <item>
      <title>Deploying code and releasing a feature are two different days</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:37:07 +0000</pubDate>
      <link>https://dev.to/goodbarber/deploying-code-and-releasing-a-feature-are-two-different-days-301n</link>
      <guid>https://dev.to/goodbarber/deploying-code-and-releasing-a-feature-are-two-different-days-301n</guid>
      <description>&lt;p&gt;Everything we run ships continuously. The engine behind the apps, the back office our customers build in, the web products around them — code moves to production at the rhythm of engineering: when it's ready, it goes.&lt;/p&gt;

&lt;p&gt;Features don't work that way. A feature has a launch: a name, documentation, support people who know it exists, sometimes a price. That's a product decision, with its own calendar, made by people who don't merge code.&lt;/p&gt;

&lt;p&gt;So the two acts had to be decoupled, and the thing that decouples them is the humblest object in software: a flag. Deploying code is an engineering act. Releasing a feature is a product decision. On &lt;a href="https://dev.to/goodbarber/you-can-add-a-word-you-cant-change-what-one-means-3e4h"&gt;a platform where every app runs on an engine all apps share&lt;/a&gt;, you don't get to conflate the two — there is no "just ship it to some users" when a deployment, by construction, reaches everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weeks in production before existing
&lt;/h2&gt;

&lt;p&gt;There's nothing exotic about a feature flag — any team that ships continuously runs some version of this, and we claim no invention here. But the day-to-day of it is still worth telling, because it's where the two calendars become visible.&lt;/p&gt;

&lt;p&gt;A feature we shipped recently spent weeks in production before it existed. One configuration value kept it off, globally — for everyone except the few people building it, who used it daily, in real conditions, in the middle of real traffic, while every other visitor saw nothing.&lt;/p&gt;

&lt;p&gt;When launch day came, nothing was deployed. One value changed. The code had been to production dozens of times by then; the feature went once. That's the whole point of the separation: the code's path to production and the feature's path to users never had to share a schedule — so neither had to wait for the other, and neither could rush the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit of release is a project
&lt;/h2&gt;

&lt;p&gt;When a feature does start to exist, it advances project by project. That's the natural granularity of a platform: a feature turns on for internal test projects first — some capabilities live for a while in builds that never leave the office — then for a few real projects, then for everyone.&lt;/p&gt;

&lt;p&gt;We never had to bolt a feature-flag system onto the product. Switching features on and off per project has been part of the platform's architecture from the start — the switchboard was there long before we ever thought of it as a release strategy.&lt;/p&gt;

&lt;p&gt;And sometimes the granularity gets embarrassingly concrete. Somewhere in our codebase there is a condition that checks whether the current project is one of exactly two hardcoded IDs. Two IDs, in the code, letting two projects do something nobody else can. Not proud of it, not ashamed of it: it's a flag in its crudest possible form, doing exactly what flags do. And note what even this crudest flag is &lt;em&gt;not&lt;/em&gt;: a separate version. Those two projects run the same engine as everyone else — the exception is a condition inside shared code, never a copy drifting away. Why we never fork that engine is a story of its own, for another day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag and the setting are the same plumbing
&lt;/h2&gt;

&lt;p&gt;Here's the part that &lt;em&gt;is&lt;/em&gt; more specific to a platform like ours — a symmetry we get to see both sides of daily.&lt;/p&gt;

&lt;p&gt;What we call a feature flag and what our customers call a setting are the same plumbing. When a builder switches comments on for their app, they flip a boolean over code that was delivered long ago — precisely what we do when we release a feature. The engine permanently runs ahead of the product: code for a capability can be fully deployed while the capability doesn't exist yet as a product — nothing references it, no screen offers it, defaults keep it inert. The day it launches, what's new isn't code. What's new is that the configuration now has a place for it.&lt;/p&gt;

&lt;p&gt;Push that logic to the end and you get a fair description of the whole platform: an app is a configuration that lights up parts of an engine. &lt;a href="https://dev.to/goodbarber/what-breaks-when-nobody-touches-your-app-for-three-years-2dgm"&gt;The engine's job is to keep every part worth lighting up&lt;/a&gt; — the configuration's job is to decide which, and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a flag can't say
&lt;/h2&gt;

&lt;p&gt;There is one thing no flag can express: &lt;em&gt;we're not sure yet.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Some of our features go out wearing a Beta label. Behind that label, at our place, there is no mechanism — no cohort, no computed exit criteria. It's an admission. Humans put it on when a feature is real but young, and humans take it off when the feature has earned it. No threshold fires; someone decides.&lt;/p&gt;

&lt;p&gt;A flag separates &lt;em&gt;deployed&lt;/em&gt; from &lt;em&gt;released&lt;/em&gt;. The label separates &lt;em&gt;released&lt;/em&gt; from &lt;em&gt;promised&lt;/em&gt;. They look alike — two booleans on the side of the same feature — but one of them governs code, and the other one gives your word.&lt;/p&gt;

</description>
      <category>nocode</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
