<?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>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>
    <item>
      <title>Every rejection becomes a check</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:13:20 +0000</pubDate>
      <link>https://dev.to/goodbarber/every-rejection-becomes-a-check-gbk</link>
      <guid>https://dev.to/goodbarber/every-rejection-becomes-a-check-gbk</guid>
      <description>&lt;p&gt;Every app we ship gets reviewed by someone else's QA. Two someones, actually — Apple and Google — each with its own rulebook, and each rulebook comes in two parts: the rules as written, and the rules as applied. The written part you can read. The applied part you learn the way case law is learned: one decision at a time.&lt;/p&gt;

&lt;p&gt;And here's the constraint that shapes everything downstream: you can't run their test suite locally. There is no command that tells you, before submission, what the review will say. Anyone who ships apps lives with this. What changes, when submitting is something you do continuously rather than occasionally, is what you're allowed to do with the answers. When the same review keeps answering you, week after week, letting the same rule surprise you twice isn't bad luck anymore. It's negligence.&lt;/p&gt;

&lt;p&gt;So that became the discipline: every time the review teaches us something — a rejection pattern, a rule change, a shift in how an old rule gets applied — the lesson turns into an explicit check on our side, run &lt;em&gt;before&lt;/em&gt; the store ever sees the app. We can't execute their suite. We can compile what it has already said.&lt;/p&gt;

&lt;p&gt;The list of checks that discipline has produced is long. Here are two entries from it — two among many, picked because they're the rejection families that &lt;a href="https://www.goodbarber.com/blog/app-store-publishing-everything-you-asked-at-our-reddit-ama-a1559/" rel="noopener noreferrer"&gt;topped the questions at our recent Reddit AMA on store publishing&lt;/a&gt;: apps that look unfinished, and declarations that don't match the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The empty shelf
&lt;/h2&gt;

&lt;p&gt;Stores reject apps that look unfinished — screens without content, sections that lead nowhere. Reasonably so: their reviewer opens the app cold, and an empty shelf reads as an abandoned store.&lt;/p&gt;

&lt;p&gt;Our answer isn't a best-practices PDF. The guardrails live in the product itself: checklists that stand between a builder and a submission, refusing to let an app walk into review hollow. What used to be advice — "fill your sections before you submit" — became a feature that checks. Advice gets skipped; checks don't.&lt;/p&gt;

&lt;p&gt;That's the pattern for everything automatable, and it's worth stating as a rule: &lt;strong&gt;when a piece of advice can be verified mechanically, it should stop being advice.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Say what you do, do what you say
&lt;/h2&gt;

&lt;p&gt;The second family of checks is subtler. A review doesn't just look at what the app does — it cross-references what the app &lt;em&gt;declares&lt;/em&gt; against what it actually does. Privacy declarations, permission requests, data-safety forms: every one of them is a claim, and a claim that doesn't match the binary is one of the most reliable ways to get rejected.&lt;/p&gt;

&lt;p&gt;It's also the most avoidable rejection there is, because the mismatch is a diff — and diffs are what machines are for. In our case, the declarations are &lt;a href="https://dev.to/goodbarber/privacy-by-design-at-the-binary-level-no-ghost-sdk-in-your-build-1bl0"&gt;deduced from the app's configuration&lt;/a&gt; rather than filled in by hand. What remains for the pre-submission check is the moving part: a builder enables a feature one week and disables it the next, and the declarations must describe the app being submitted today, not the app as it looked when someone last thought about privacy. So coherence gets verified again, at the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line between the machine and the eye
&lt;/h2&gt;

&lt;p&gt;Not everything compiles into a check. A rule that says "no placeholder content" automates well. A rule whose application shifted last quarter, or that hinges on how a reviewer will read a particular screen — that one lives in judgment, not in code.&lt;/p&gt;

&lt;p&gt;So the frontier is drawn plainly. Everything that can be verified mechanically is handed to the builder as a guardrail inside the product — they see it, they fix it, no expertise required. What can't be automated goes under an expert eye before submission: &lt;a href="https://dev.to/goodbarber/what-breaks-when-nobody-touches-your-app-for-three-years-2dgm"&gt;the store rules keep moving&lt;/a&gt;, and someone whose job is to read every change sees patterns no individual builder submitting once a year could. The review on the other side is run by humans; the last line on ours is too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failing first
&lt;/h2&gt;

&lt;p&gt;None of this makes rejection impossible — a suite you can't run will always keep some surprises. What it does is more modest and more valuable: it guarantees that no app fails in front of Apple or Google for a reason we already knew about. The known rules are checked by machines. The learned patterns are checked by people. And every new lesson joins the list, which means the list only grows in one direction.&lt;/p&gt;

&lt;p&gt;You can't run someone else's test suite. You can make sure yours fails first.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>ios</category>
      <category>android</category>
      <category>architecture</category>
    </item>
    <item>
      <title>White-label is a subtraction job</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 25 Aug 2026 08:22:17 +0000</pubDate>
      <link>https://dev.to/goodbarber/white-label-is-a-subtraction-job-53mf</link>
      <guid>https://dev.to/goodbarber/white-label-is-a-subtraction-job-53mf</guid>
      <description>&lt;p&gt;Some of our resellers run the platform under their own brand. Their customers build apps on infrastructure that says someone else's name everywhere — the reseller's, not ours.&lt;/p&gt;

&lt;p&gt;From a distance, that looks like a feature you add: swap the logo, change a color, done. From inside, it's the opposite kind of work. White-label is a subtraction job: finding every place your own name hides in your own product, and taking it out. There are hundreds of those places. Nobody's list is ever quite finished — a product that keeps shipping keeps creating new spots for a name to leak into.&lt;/p&gt;

&lt;p&gt;Why resellers ask for this — and how the ask went from a fear to a claim — is &lt;a href="https://jeromegranados.substack.com/p/agencies-asked-us-to-hide-our-name" rel="noopener noreferrer"&gt;a story Jérôme tells&lt;/a&gt;. This is the engineering half: what honoring the ask actually takes.&lt;/p&gt;

&lt;p&gt;Three of them, picked because each teaches something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The email you didn't sign
&lt;/h2&gt;

&lt;p&gt;Transactional emails — the password reset, the order confirmation — are the easy case: put the reseller's name in the sender field. Except an email carries more identity than its display name. There's the &lt;em&gt;via&lt;/em&gt; that some inboxes surface next to the sender. There are the technical headers that most recipients will never open — until the one technically-minded customer does, and finds a name that shouldn't be there. Subtracting yourself from an email means subtracting yourself from the parts of it nobody usually reads. Someone always reads them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The address on the door
&lt;/h2&gt;

&lt;p&gt;A reseller's customers log into a back office. That back office lives at an address — and the address is part of the product. It can't be goodbarber.com, not even as a redirect on the way in. So domains are part of the subtraction: the workspace a customer uses every day has to carry, down to its URL, the brand of the company they bought it from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page nobody tests
&lt;/h2&gt;

&lt;p&gt;Error pages. When everything works, every screen has been reviewed a hundred times. When something breaks, you land on pages nobody rehearses — and a default error page is precisely where a platform's real name loves to surface. Your brand showing up at the exact moment something fails is the worst possible cameo. So the subtraction has to cover the unhappy paths too: the 404s, the maintenance screens, the "something went wrong" of it all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things we never had to subtract
&lt;/h2&gt;

&lt;p&gt;The most interesting part of the inventory is what's &lt;em&gt;not&lt;/em&gt; on it.&lt;/p&gt;

&lt;p&gt;An app built on the platform ships to the stores from the customer's own developer account. Their name on the listing, their certificates, their relationship with Apple and Google. There is nothing to erase there, because the architecture never wrote us into it: the app belonged to its owner from the first day, reseller or no reseller. &lt;a href="https://dev.to/goodbarber/deciding-what-an-agent-should-do-not-just-what-it-can-5h35"&gt;Even our agent playbook ships white-label&lt;/a&gt; — resellers redistribute it under their own brand, because it was written to be redistributed.&lt;/p&gt;

&lt;p&gt;That's the quiet lesson of this whole exercise. The subtraction you do by hand is corrective; the best subtraction is structural. Every place where the platform never claimed what belongs to the customer is a place you'll never have to scrub.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finish line
&lt;/h2&gt;

&lt;p&gt;How do you know the job is done? Walk down the chain and ask, at each step, whose brand is visible.&lt;/p&gt;

&lt;p&gt;The end user of an app sees the brand of the app's owner. The app's owner — the reseller's customer — sees the reseller's brand: on the back office, in the emails, on the error pages. And us? Nowhere in that chain. Not smaller, not discreet, not in the footer. Absent.&lt;/p&gt;

&lt;p&gt;That's the acceptance test, and it's stricter than it sounds. A brand that shows up small is still a brand that shows up. White-label isn't your name in a modest font — it's your name gone.&lt;/p&gt;

&lt;p&gt;We spent years building a brand, and then built the capability whose entire job is to make it disappear. Those aren't opposites. They're the same product, taken seriously twice.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>saas</category>
      <category>nocode</category>
    </item>
    <item>
      <title>Models retire faster than operating systems</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Mon, 17 Aug 2026 08:18:31 +0000</pubDate>
      <link>https://dev.to/goodbarber/models-retire-faster-than-operating-systems-275p</link>
      <guid>https://dev.to/goodbarber/models-retire-faster-than-operating-systems-275p</guid>
      <description>&lt;p&gt;When an operating system &lt;a href="https://dev.to/goodbarber/what-breaks-when-nobody-touches-your-app-for-three-years-2dgm"&gt;deprecates an API&lt;/a&gt;, you get a year's notice, a migration guide, and a conference talk. When a model provider retires a model, you get a blog post, a date a few months out, and a designated replacement — allegedly better. Our AI features run on the most perishable dependency anywhere in the platform.&lt;/p&gt;

&lt;p&gt;We haven't been forced through an emergency swap in production yet. What we have done, constantly, is try models — a lot of them. And somewhere along the way, each experiment stopped being a test and became a rehearsal: the day a retirement notice lands on a model we depend on, the move it triggers is one we've already made dozens of times.&lt;/p&gt;

&lt;p&gt;Here's what makes that move routine instead of a crisis.&lt;/p&gt;

&lt;h2&gt;
  
  
  A model is a catalog line
&lt;/h2&gt;

&lt;p&gt;In our generation pipeline, a model isn't a choice wired into the code. It's an entry in a catalog: which provider serves it, which key unlocks it, what it costs. The catalog covers essentially every major provider — OpenAI, Anthropic, Google, etc. — and production runs a deliberately trimmed selection of it.&lt;/p&gt;

&lt;p&gt;The other half of the setup: each step of the pipeline is mapped to its own model. The step that plans doesn't need the model that writes; the step that repairs a single field doesn't need the model that generates a whole component. So a swap has a scope. Changing the model behind one step is a configuration change with a bounded blast radius — not a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt faces the model. The evals face us.
&lt;/h2&gt;

&lt;p&gt;Two kinds of documents govern the model's work, and they look in opposite directions.&lt;/p&gt;

&lt;p&gt;The prompt faces the model. It speaks the model's language, and when we try a new family of models, adapting it is real work — but marginal work. Each family has its dialects: how it likes constraints phrased, what it does with structure, where it needs repetition. Moving between them is translation, not renegotiation. The contract — what must come out: the schema, the constraints, what to do when a request is out of scope — doesn't move.&lt;/p&gt;

&lt;p&gt;The evals face us. This is the part that took us the longest to see clearly: an eval suite that tries to anticipate what a given model will answer is doing the prompt's job, badly. Ours validate the output against our standard — does this parse, does it respect the schema, does a conversational question get a conversational answer instead of a broken build — and that standard is, by construction, common to every model. The test fixtures come from real production failures. The thresholds are written down. And nothing in them mentions a model by name.&lt;/p&gt;

&lt;p&gt;That's the whole trick, and it fits in one sentence: &lt;strong&gt;the prompt is bilingual; the standard isn't.&lt;/strong&gt; Swapping a model means changing who you talk to — not what you accept.&lt;/p&gt;

&lt;h2&gt;
  
  
  The customer holds the same dial
&lt;/h2&gt;

&lt;p&gt;There's a simple way to check whether a component is genuinely swappable: see who you're willing to let swap it.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://dev.to/goodbarber/your-chatbot-is-a-second-door-onto-your-content-535h"&gt;our RAG chatbot&lt;/a&gt;, the customer picks the tier of model it runs on — a light one for high-volume answering, a more capable one when nuance justifies it. Their content, their audience, their bill: their dial. We could not hand that dial to thousands of customers if changing the model changed what the feature &lt;em&gt;is&lt;/em&gt;. The feature is the retrieval, the access rules, the grounding in their content. The model is staffing.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Better" is also a regression
&lt;/h2&gt;

&lt;p&gt;The trap in every swap isn't the new model being worse. It's the new model being &lt;em&gt;different&lt;/em&gt; — smarter, even — and answering in ways the old contract never anticipated. More fluent refusals. Cleverer formats nobody asked for. A model that upgrades your feature's behavior without your consent has broken it, just politely.&lt;/p&gt;

&lt;p&gt;Which is why the plan for retirement day is deliberately unheroic: add a catalog line, adapt the dialect, run the harness, read the thresholds. &lt;a href="https://dev.to/goodbarber/shipping-a-component-that-never-answers-the-same-way-twice-5eo8"&gt;A component that never answers the same way twice&lt;/a&gt; taught us to treat the model as a vendor under contract. This is the other half of that discipline, the one you only see over time:&lt;/p&gt;

&lt;p&gt;We don't upgrade models. We re-certify them.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>architecture</category>
      <category>nocode</category>
    </item>
    <item>
      <title>Feeding an app from someone else's CMS</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:34:58 +0000</pubDate>
      <link>https://dev.to/goodbarber/feeding-an-app-from-someone-elses-cms-61l</link>
      <guid>https://dev.to/goodbarber/feeding-an-app-from-someone-elses-cms-61l</guid>
      <description>&lt;p&gt;An app's content has a home. Often that home is ours: articles, events, podcasts, photos written and managed in the GoodBarber back office. But a lot of content already lives somewhere else — a WordPress a team has run for years, a YouTube channel, a podcast host, a shared calendar. Nobody re-types content they already maintain.&lt;/p&gt;

&lt;p&gt;So an app has to eat from other people's kitchens. The engineering question is what language dinner arrives in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standards are dialects
&lt;/h2&gt;

&lt;p&gt;Take calendars. iCal has been a standard since the late nineties — our oldest test file for it is dated 2002 — and in theory every emitter on earth speaks it. In practice, every emitter speaks its own accent of it: fields that shouldn't be empty but are, recurrence rules stretched past what the spec imagined, encodings from another era, dates that contradict each other inside a single event.&lt;/p&gt;

&lt;p&gt;Our calendar connector is full of exceptions, and we don't apologize for a single one. Each exception is a real customer's real calendar — one that a spec-perfect parser would have politely refused. A connector that only accepts the standard as written connects you to nobody.&lt;/p&gt;

&lt;p&gt;WordPress taught us the outer limit of that patience. What lives behind that name is so heterogeneous — versions, themes, plugins, half-disabled APIs — that reading from the outside eventually wasn't enough: we ended up shipping our own plugin on the WordPress side, so that at least one end of the conversation would be predictable. Even with our code at both ends, it's still delicate. When reading fails, equip the writer.&lt;/p&gt;

&lt;h2&gt;
  
  
  One language on arrival
&lt;/h2&gt;

&lt;p&gt;Whatever the source speaks, everything lands in the same internal shape. An event from a hand-written iCal feed, an event from Google Calendar and an event created in our CMS become the same kind of object — same fields, same rules, served to the app through the same operations: fetch an item, list the categories, search. The app never learns where its content was born.&lt;/p&gt;

&lt;p&gt;That's the actual job of a connector. Fetching is plumbing; the work is translation — from whatever dialect the source speaks into &lt;a href="https://dev.to/goodbarber/you-can-add-a-word-you-cant-change-what-one-means-3e4h"&gt;the same internal grammar&lt;/a&gt; everything else in the platform already reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translate once, everything works
&lt;/h2&gt;

&lt;p&gt;The dividend shows up everywhere downstream. A new article lands in an imported feed: the automatic push can notify subscribers, because to the push system it's just a new article. Search indexes it. The app renders it natively, with the app's design, not as a foreign web page in a frame. For compatible content, &lt;a href="https://dev.to/goodbarber/your-chatbot-is-a-second-door-onto-your-content-535h"&gt;the chatbot grounds its answers in it&lt;/a&gt;, the same way it grounds them in content written in our own CMS.&lt;/p&gt;

&lt;p&gt;None of these features contains a line of code about WordPress or iCal. They read the internal language, and the connector already did the translating. Import badly — pass the source's quirks through — and every one of those features inherits the quirks. Translate once, properly, at the border, and everything behind the border stays simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connectors die. Content doesn't.
&lt;/h2&gt;

&lt;p&gt;Deep in our oldest code sits a directory of connectors for platforms that no longer exist. Google+. Picasa. MySpace. Each was worth a connector once; each is a tombstone now. That's the quiet lifecycle rule of this whole domain: &lt;strong&gt;a connector dies when the far side dies.&lt;/strong&gt; You can maintain your half of a conversation forever — if the other half hangs up, the wire goes silent anyway.&lt;/p&gt;

&lt;p&gt;The apps those connectors fed didn't die with them. Their content had already crossed the border, translated into a shape that doesn't belong to any platform. When a source disappears, you lose a pipe, not a library.&lt;/p&gt;

&lt;p&gt;Which is the real answer to the question we started with. The durable part of an import was never the pipe — pipes rust, sources vanish, standards drift into dialects. The durable part is the language things arrive in. That one, we get to keep.&lt;/p&gt;

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