<?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: GoodBarber</title>
    <description>The latest articles on DEV Community by GoodBarber (goodbarber).</description>
    <link>https://dev.to/goodbarber</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%2Forganization%2Fprofile_image%2F14004%2F83b22df0-0fa0-4978-8d15-833839b116e9.png</url>
      <title>DEV Community: GoodBarber</title>
      <link>https://dev.to/goodbarber</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/goodbarber"/>
    <language>en</language>
    <item>
      <title>How do you give local content in a WKWebView a real https origin? (iOS 17)</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:45:29 +0000</pubDate>
      <link>https://dev.to/goodbarber/how-do-you-give-local-content-in-a-wkwebview-a-real-https-origin-ios-17-2h64</link>
      <guid>https://dev.to/goodbarber/how-do-you-give-local-content-in-a-wkwebview-a-real-https-origin-ios-17-2h64</guid>
      <description>&lt;p&gt;On September 16 I opened a test widget on my iPhone. At the top, a quote that loads when the widget opens. Under it, two text areas and a Compare button that returns a similarity score. Two calls to the same third-party API with the same key: a GET for the quote, a POST for the score.&lt;/p&gt;

&lt;p&gt;The quote appeared. The Compare button answered with the only sentence its code can say when that call fails: "Impossible de calculer la similarité. Veuillez vérifier votre connexion." Unable to compute the similarity, please check your connection. The connection was fine, my Android test phone did exactly the same, and in a browser both calls worked.&lt;/p&gt;

&lt;p&gt;GET works, POST fails. I spent a morning on the verb. The cause was the page's origin, and the GET had never worked.&lt;/p&gt;

&lt;p&gt;This piece is about that origin: where a page in a &lt;code&gt;WKWebView&lt;/code&gt; gets one when its files live on the device, how our iOS engine now serves them from a fixed &lt;code&gt;https://secure.internal&lt;/code&gt; with a proxy API that arrived in iOS 17, and what changing an origin breaks in places no crash log reaches. The widget comes back at the end, once the dates make sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why local content needs an origin at all
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app builder for native iOS and Android apps, some sections of an app are web content: custom code written by the app's owner, HTML widgets, extensions. The files live on the device so that the section opens offline. On iOS they are displayed in a &lt;code&gt;WKWebView&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How those files are served decides whether the page is a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts" rel="noopener noreferrer"&gt;secure context&lt;/a&gt;, a prerequisite for APIs such as &lt;code&gt;crypto.subtle&lt;/code&gt;. It also sets the origin seen by CORS and CSP checks, cookie behaviour, and where &lt;code&gt;localStorage&lt;/code&gt; and IndexedDB data are stored.&lt;/p&gt;

&lt;p&gt;One of those checks is ours. My team is piloting an extension builder, where I look after the backend: the owner of an app asks for a feature in a sentence, a model writes it, and the result runs in the app's web version and inside its native apps. A widget that needs a private API key never holds it. The page frames a small page served by our token issuer, gets a short-lived token, and calls a proxy of ours that keeps the key. The issuer lets only approved origins frame its page, through a CSP &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors" rel="noopener noreferrer"&gt;&lt;code&gt;frame-ancestors&lt;/code&gt;&lt;/a&gt; directive. So the origin a native shell gives its local files decides whether any of those widgets can work at all. The builder has not shipped to customers, and the phones in this story are my own test devices.&lt;/p&gt;

&lt;p&gt;Change the serving mechanism and all of that changes with it. We went through three versions in one year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three origins in one year
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;How the page was served&lt;/th&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;th&gt;What it cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before June 2026&lt;/td&gt;
&lt;td&gt;an HTML string with a custom base URL, files through a &lt;code&gt;WKURLSchemeHandler&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;a custom scheme&lt;/td&gt;
&lt;td&gt;not a secure context, an &lt;code&gt;Origin&lt;/code&gt; header that third-party servers do not recognise, &lt;code&gt;Secure&lt;/code&gt; cookies ignored, URL rewriting and a permissive CSP to hold it together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June to August 2026&lt;/td&gt;
&lt;td&gt;a small HTTP server inside the app, bound to loopback&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a secure context and plain HTTP semantics, but an IP address and a changing port in the origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;since September 2, 2026&lt;/td&gt;
&lt;td&gt;a loopback CONNECT proxy in front of a TLS listener&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://secure.internal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS 17 minimum for this approach, and web storage starting from empty once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second step was already a big one, and our iOS team built it as a real server: request parser, range requests for media, keep-alive, recovery when the listener dies. It also relays the page's cross-origin &lt;code&gt;fetch&lt;/code&gt; calls, which the shell rewrites towards it. Loopback is a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts" rel="noopener noreferrer"&gt;potentially trustworthy origin&lt;/a&gt;, so the page became a secure context and third-party scripts finally saw an &lt;code&gt;http&lt;/code&gt; origin they understood.&lt;/p&gt;

&lt;p&gt;The remaining problem was the port, assigned at runtime. There was no single origin to put in a remote allowlist. On July 30 the token issuer started accepting &lt;code&gt;http://127.0.0.1:*&lt;/code&gt;, which is valid CSP and accepts any port, including any unrelated local server on the phone. A fixed &lt;code&gt;https&lt;/code&gt; name would give the app and those services a value they could agree on.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebKit will not hand over https
&lt;/h2&gt;

&lt;p&gt;Apple's documentation for &lt;a href="https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/seturlschemehandler%28_:forurlscheme:%29" rel="noopener noreferrer"&gt;&lt;code&gt;setURLSchemeHandler(_:forURLScheme:)&lt;/code&gt;&lt;/a&gt; rules out the direct approach: registering a handler for a scheme WebKit already handles, such as &lt;code&gt;https&lt;/code&gt;, raises an exception.&lt;/p&gt;

&lt;p&gt;Android takes the opposite position. &lt;code&gt;shouldInterceptRequest&lt;/code&gt; lets an app answer any request, &lt;code&gt;https&lt;/code&gt; included, and &lt;a href="https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader" rel="noopener noreferrer"&gt;&lt;code&gt;WebViewAssetLoader&lt;/code&gt;&lt;/a&gt; packages the pattern with a domain reserved for it, &lt;code&gt;appassets.androidplatform.net&lt;/code&gt;. Our Android engine moved from &lt;code&gt;file://&lt;/code&gt; to that loader on July 31, a fixed &lt;code&gt;https&lt;/code&gt; origin came with it, and the issuer accepted it the same day.&lt;/p&gt;

&lt;p&gt;That Android approach does not transfer to &lt;code&gt;WKURLSchemeHandler&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a real certificate for a real domain?
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is a public hostname that resolves to &lt;code&gt;127.0.0.1&lt;/code&gt;, a certificate from a public authority, and the private key shipped in the app. Let's Encrypt has a page about &lt;a href="https://letsencrypt.org/docs/certificates-for-localhost/" rel="noopener noreferrer"&gt;certificates for localhost&lt;/a&gt; whose answer is: don't. A private key distributed in an app is a compromised key, the certificate may be revoked, and the DNS lookup gives an attacker something to intercept. It also makes an offline section depend on DNS.&lt;/p&gt;

&lt;p&gt;We could still use a certificate whose trust was limited to our own WebView. It did not need recognition from a public authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  The piece that arrived with iOS 17
&lt;/h2&gt;

&lt;p&gt;iOS 17 added &lt;a href="https://developer.apple.com/documentation/webkit/wkwebsitedatastore/proxyconfigurations-cdc1" rel="noopener noreferrer"&gt;&lt;code&gt;proxyConfigurations&lt;/code&gt;&lt;/a&gt; to &lt;code&gt;WKWebsiteDataStore&lt;/code&gt;, fed by the Network framework's &lt;a href="https://developer.apple.com/documentation/network/proxyconfiguration" rel="noopener noreferrer"&gt;&lt;code&gt;ProxyConfiguration&lt;/code&gt;&lt;/a&gt;. Apple presented it at WWDC23 for &lt;a href="https://developer.apple.com/videos/play/wwdc2023/10002/" rel="noopener noreferrer"&gt;network relays&lt;/a&gt;, with a remote relay in the example. Nothing says the proxy has to be remote.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Network&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;WebKit&lt;/span&gt;

&lt;span class="c1"&gt;// The CONNECT proxy is a listener our own app opened on loopback.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;proxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;connectProxyPort&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;configuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ProxyConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;httpCONNECTProxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tlsOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matchDomains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"secure.internal"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;// only this name uses the proxy&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowFailover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;               &lt;span class="c1"&gt;// never try a direct connection&lt;/span&gt;

&lt;span class="kt"&gt;WKWebsiteDataStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proxyConfigurations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two properties carry the design. With &lt;code&gt;matchDomains&lt;/code&gt;, WebKit sends &lt;code&gt;CONNECT secure.internal:443&lt;/code&gt; to our listener instead of resolving the name, so the name never reaches DNS and the section opens in airplane mode. With &lt;code&gt;allowFailover&lt;/code&gt; off, WebKit never falls back to a direct connection, so the name cannot leak to a resolver while our server is restarting.&lt;/p&gt;

&lt;p&gt;Inside the app, three loopback listeners do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A TLS listener serves the files. Its identity is embedded in the app: self-signed, RSA 2048, a single SAN &lt;code&gt;secure.internal&lt;/code&gt;, TLS 1.2 minimum, ALPN &lt;code&gt;http/1.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A CONNECT proxy on a port the OS assigns. It accepts exactly one request, &lt;code&gt;CONNECT secure.internal:443&lt;/code&gt;, and pipes the bytes to the TLS listener. Anything else gets a 403.&lt;/li&gt;
&lt;li&gt;A plain HTTP fallback for media, in case the media stack does not follow the data store's proxy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The configuration is pushed again every time the server restarts, since the port changes, and before the WebViews are told to reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accept one certificate, for one host
&lt;/h2&gt;

&lt;p&gt;A self-signed certificate fails the system's trust evaluation. That is expected, and the navigation delegate is where it is settled. Ours is Objective-C. Here is the same logic in Swift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;WKWebView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;didReceive&lt;/span&gt; &lt;span class="nv"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URLAuthenticationChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="nv"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;@escaping&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;URLSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;AuthChallengeDisposition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;URLCredential&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;space&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;protectionSpace&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authenticationMethod&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;NSURLAuthenticationMethodServerTrust&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;caseInsensitiveCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secure.internal"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orderedSame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;trust&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTrust&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;performDefaultHandling&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// every other host: the normal path&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SecTrustCopyCertificateChain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;SecCertificate&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;leaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;SecCertificateCopyData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;leaf&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;pinnedCertificateDER&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                     &lt;span class="c1"&gt;// byte-identical to the embedded one&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;useCredential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;URLCredential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cancelAuthenticationChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exception applies only to that host and that exact certificate. Every other host follows normal trust evaluation.&lt;/p&gt;

&lt;p&gt;The embedded key is shared across our apps and can be extracted from a binary. We treat it accordingly. The design rests on the local routing and on the delegate's narrow trust decision; it does not give the certificate public authority or make it an app identity.&lt;/p&gt;

&lt;p&gt;One more gate sits in front of the files. Loopback listeners are reachable by other processes on the device, so every URL starts with a random token generated for the session, and a request without it gets a 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the name
&lt;/h2&gt;

&lt;p&gt;Since the name never touches DNS, it is cosmetic. It shows up in &lt;code&gt;location.origin&lt;/code&gt;, in the web inspector and in server-side allowlists, never in front of a user. Two constraints still apply: the suffix must never be delegated on the public DNS, and no certificate authority must ever issue for it.&lt;/p&gt;

&lt;p&gt;That rules out &lt;code&gt;.app&lt;/code&gt;, which is a real top-level domain, and &lt;code&gt;.local&lt;/code&gt;, which belongs to multicast DNS and answers on the local network. It points at &lt;code&gt;.internal&lt;/code&gt;, which the ICANN Board &lt;a href="https://www.icann.org/en/board-activities-and-meetings/materials/approved-resolutions-special-meeting-of-the-icann-board-29-07-2024-en" rel="noopener noreferrer"&gt;reserved for private-use applications&lt;/a&gt; on July 29, 2024, permanently excluding it from delegation in the DNS root zone.&lt;/p&gt;

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

&lt;p&gt;The new engine landed on September 2, and the issuer swapped &lt;code&gt;http://127.0.0.1:*&lt;/code&gt; for &lt;code&gt;https://secure.internal&lt;/code&gt; the same day. These are the September 4 verification runs: iPhone 15 Pro simulator, iOS 17.4, our internal app in Debug, two real customer apps loaded in it.&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;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page loads logging &lt;code&gt;location.origin&lt;/code&gt; and &lt;code&gt;isSecureContext&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;43 loads across plugin sections, HTML widgets and scratch sections, 43 times &lt;code&gt;https://secure.internal&lt;/code&gt; and &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;crypto.subtle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS handshake through the tunnel&lt;/td&gt;
&lt;td&gt;TLS 1.3, 6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import of the embedded identity&lt;/td&gt;
&lt;td&gt;3.7 to 4.0 ms, measured four times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live CONNECT tunnels&lt;/td&gt;
&lt;td&gt;peak of 1, back to 0 after each cycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native bridge, storage round trip, cross-origin GET and POST relays&lt;/td&gt;
&lt;td&gt;exercised on 9 scopes towards 3 remote hosts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backgrounding for 50 seconds, then return&lt;/td&gt;
&lt;td&gt;the WebView survives and the appear event fires again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video rendering&lt;/td&gt;
&lt;td&gt;not testable on the simulator, whose WebKit GPU process is denied GPU access; normal on a device&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the https origin changed
&lt;/h2&gt;

&lt;p&gt;Mixed content appeared. Old custom code links &lt;code&gt;http://fonts.googleapis.com/…&lt;/code&gt; stylesheets, in dozens of HTML files across 12 sections of a single test app. Under an &lt;code&gt;http&lt;/code&gt; origin nobody noticed. Under &lt;code&gt;https&lt;/code&gt;, WebKit blocks them as active mixed content and the page silently loses its fonts. The server now adds &lt;code&gt;Content-Security-Policy: upgrade-insecure-requests&lt;/code&gt; to HTML documents, and to nothing else. Our Android engine met the same files after its own migration.&lt;/p&gt;

&lt;p&gt;Tunnels have to die on the first EOF. A 28-minute capture showed 68 CONNECT tunnels alive at the same instant, all killed together with &lt;code&gt;ENETDOWN&lt;/code&gt; when the app was suspended. Our relay waited for both directions to finish before closing, and WebKit keeps its side open and idle. A CONNECT tunnel is done as soon as either peer stops writing. With that rule, and a counter logged on every open and close, the peak is 1. &lt;code&gt;ENETDOWN&lt;/code&gt; on suspension is now logged as a routine end of life, because it is one.&lt;/p&gt;

&lt;p&gt;Web storage starts from empty. &lt;code&gt;localStorage&lt;/code&gt;, IndexedDB and cookies are filed by origin, and WebKit offers no supported way to move them from one origin to another: &lt;code&gt;WKWebsiteDataStore&lt;/code&gt; can enumerate and delete by origin, not read or rewrite. A custom section that kept a login token asks for the login once after the update. That is the visible price of an origin change.&lt;/p&gt;

&lt;p&gt;The fourth change has no log line at all. It is the widget from the first paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to September 16
&lt;/h2&gt;

&lt;p&gt;The phones ran internal builds of our shells from early summer. That morning I built both engines on my laptop and ran the same widget on fresh installs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Native shell&lt;/th&gt;
&lt;th&gt;GET, the quote&lt;/th&gt;
&lt;th&gt;POST, the score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;none, a browser&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS simulator&lt;/td&gt;
&lt;td&gt;built that morning&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android emulator&lt;/td&gt;
&lt;td&gt;built that morning&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok, 48%, no console error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test iPhone&lt;/td&gt;
&lt;td&gt;internal build, early summer&lt;/td&gt;
&lt;td&gt;a quote was displayed&lt;/td&gt;
&lt;td&gt;error message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test Android phone&lt;/td&gt;
&lt;td&gt;internal build, early summer&lt;/td&gt;
&lt;td&gt;a quote was displayed&lt;/td&gt;
&lt;td&gt;error message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same bundle, same backend, same key. The only variable left was the shell installed on the phones. Read the last two rows again: I wrote "a quote was displayed", not "ok". That morning I wrote "ok".&lt;/p&gt;

&lt;p&gt;The verb was a good suspect, because a WebView does treat a request with a body differently. Browsers attach an &lt;code&gt;Origin&lt;/code&gt; header to a same-origin POST and not to a same-origin GET, as the &lt;a href="https://fetch.spec.whatwg.org/#origin-header" rel="noopener noreferrer"&gt;Fetch standard&lt;/a&gt; specifies, so any guard that compares &lt;code&gt;Origin&lt;/code&gt; to an expected value is only ever exercised by POSTs. On Android, &lt;code&gt;shouldInterceptRequest&lt;/code&gt; hands the app a &lt;a href="https://developer.android.com/reference/android/webkit/WebResourceRequest" rel="noopener noreferrer"&gt;&lt;code&gt;WebResourceRequest&lt;/code&gt;&lt;/a&gt; with a URL, a method and headers, and no body. On iOS, our relay has to buffer the whole body before forwarding anything, and a GET has nothing to buffer. One fact should have cooled me down: both calls carried a bearer token in an &lt;code&gt;Authorization&lt;/code&gt; header, which makes both of them preflighted cross-origin requests. For CORS, my GET and my POST were in the same class.&lt;/p&gt;

&lt;p&gt;The coding agent I was pairing with asked for one trace from the phone, Safari's Web Inspector or &lt;code&gt;chrome://inspect&lt;/code&gt;. I chose to build instead, because a build feels like progress. It worked, and this went into my notes: an outdated app relays the GETs and loses the body of the POSTs; check the build date before you look at the server. The second half is good advice. The first half is a mechanism nobody observed. The fix was right, the cause was invented.&lt;/p&gt;

&lt;p&gt;Two days later I asked the same agent to draft an article from that session. Before writing the mechanism down for strangers, it checked it against git history, and the dates in this article do not allow it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The iOS relay has buffered the complete body since the day it was written, June 4. There is no older relay that drops bodies for a phone to be outdated with.&lt;/li&gt;
&lt;li&gt;On September 2 the issuer stopped accepting &lt;code&gt;http://127.0.0.1:*&lt;/code&gt;. My commit message gives the reason: the feature is unreleased, and an app is rebuilt when the feature is activated for it. An iPhone build older than September 2 can no longer frame the token page. No frame, no token. No token, no call, whatever the verb.&lt;/li&gt;
&lt;li&gt;Android builds older than July 31 load the page from &lt;code&gt;file://&lt;/code&gt;. A &lt;code&gt;file://&lt;/code&gt; page has an opaque origin, and an opaque origin cannot be listed in &lt;code&gt;frame-ancestors&lt;/code&gt;. No token there either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So on those phones the GET could not have worked. Yet both showed a quote. The simulator still had the bundle it downloaded on the 16th, with the function that loads the quote, as the model wrote it (key name and author line shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;gbProxyFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/quotes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Erreur API&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quotes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* render quotes[0] */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quote-text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;« La simplicité est la sophistication suprême. »&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cm"&gt;/* ...and "Léonard de Vinci" in quote-author */&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a shell that cannot frame the token page, the token request fails, the promise rejects, and this &lt;code&gt;catch&lt;/code&gt; prints Leonardo da Vinci. That is what both phones were showing: Leonardo, in French. The POST went through the same failure with nothing to fall back on, so it told me to check my connection.&lt;/p&gt;

&lt;p&gt;The asymmetry was not between two HTTP verbs. It was between two &lt;code&gt;catch&lt;/code&gt; blocks. Nothing had worked on the phones; one of the two failures was dressed as a success. The evidence was on the screen: the API returns its quotes in English, the fallback is in French. And the SDK rejects with a typed error whose &lt;code&gt;kind&lt;/code&gt; names the stage that failed, &lt;code&gt;init&lt;/code&gt; when no token could be obtained. Both &lt;code&gt;catch&lt;/code&gt; blocks received it. Neither read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  An origin is versioned by the binary
&lt;/h2&gt;

&lt;p&gt;A feature that lives in a web page inherits the page's origin, and inside a native app the shell chooses it: &lt;code&gt;file://&lt;/code&gt;, a loopback port, a custom scheme, a fixed &lt;code&gt;https&lt;/code&gt; name. CORS, CSP &lt;code&gt;frame-ancestors&lt;/code&gt;, cookies and every server-side allowlist key on it. So every allowlist that names an origin is, in practice, a list of builds, and the builds are installed on people's phones on dates you do not control.&lt;/p&gt;

&lt;p&gt;The switch of September 2 was a deliberate decision, and it holds: a rebuilt app carries the current shell, the current shell serves the origin the issuer expects, and the two move together. An app owner never meets the old origin. That works because the platform owns both ends, the native shell and the services behind it, which is also why every date in this article is known to the day. The one way to meet the old origin was to test an unreleased feature on an internal build from early summer, on the phone of the person who made the switch.&lt;/p&gt;

&lt;p&gt;On the company blog I wrote about &lt;a href="https://www.goodbarber.com/blog/what-quietly-breaks-when-you-don-t-update-your-app-and-why-you-never-notice-on-goodbarber-a1566/" rel="noopener noreferrer"&gt;what quietly breaks when an app is not updated&lt;/a&gt;: store visibility, push delivery, OS changes. This is the same decay seen from the server side. An allowlist moves, and every build older than that date loses a capability, without a crash and without a line in the phone's log.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check first now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is the half that works real?&lt;/strong&gt; Before comparing a GET and a POST, find out whether the GET's data came from the network. Search the page for &lt;code&gt;catch&lt;/code&gt;. If a model wrote the code, expect a fallback: models are polite about failure, they fill the hole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One trace from the failing device.&lt;/strong&gt; Web Inspector or &lt;code&gt;chrome://inspect&lt;/code&gt;, five minutes. It would have shown no request to the proxy at all, neither GET nor POST, and a refused frame in the console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build date of the shell,&lt;/strong&gt; before any server log, whenever the report says "fine on the web, broken in the app". Then compare it with the dates your allowlists changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Substitution, last.&lt;/strong&gt; The same bundle on a fresh shell proves where the fault is not, and says nothing about what happened on the old one. "Works on the simulator I built this morning" and "works on the phone in my pocket" are different claims. That gap is where a correct fix picked up an invented cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model's fallback stays. A quote widget that shows a default quote instead of a broken box is the right behaviour for the person using the app; it is only a trap for the person debugging it. So its firing becomes visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[quote] proxy call failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the line that was missing&lt;/span&gt;
  &lt;span class="nf"&gt;showFallbackQuote&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that line, the first look at the console says &lt;code&gt;init&lt;/code&gt;, no token, before anyone opens Xcode. Degrade gracefully for the user, loudly for the developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the migration
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;https://secure.internal&lt;/code&gt; is shared across our apps, just as &lt;code&gt;appassets.androidplatform.net&lt;/code&gt; is shared by Android apps using that loader. It gives browser policies a stable value to compare. Authentication still needs its own mechanism, which is what the token is for.&lt;/p&gt;

&lt;p&gt;The practical gain is that local content gets ordinary HTTPS behaviour while staying available offline. The cost sits in everything attached to the previous origin: storage, remote allowlists, and the builds already installed. That is why the new name is boring, fixed, and meant to stay. Changing it later is another migration, even if every HTML file stays the same, and the phones in people's pockets will not follow.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>webdev</category>
      <category>security</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The assistant is making the native app mandatory again</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Thu, 01 Oct 2026 07:25:35 +0000</pubDate>
      <link>https://dev.to/goodbarber/the-assistant-is-making-the-native-app-mandatory-again-dh5</link>
      <guid>https://dev.to/goodbarber/the-assistant-is-making-the-native-app-mandatory-again-dh5</guid>
      <description>&lt;p&gt;For ten years, a well-built website was an acceptable answer to the question "do I need an app?". This year, Apple and Google settled how the phone's assistant will act for us, and that answer no longer quite holds. The app is becoming an entry ticket again, and not for the reasons of 2008.&lt;/p&gt;

&lt;p&gt;My colleague Dominique Siacci explained at the end of September &lt;a href="https://dev.to/goodbarber/agents-wont-kill-apps-on-a-phone-they-cant-act-without-them-58h6"&gt;why agents won't kill apps&lt;/a&gt;: on a phone, they can't act without them, because the app already carries the identity, the rights and the relationship an agent needs. I'd like to draw the consequence for the question we've been asked for fifteen years, and which is changing its answer for the third time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three answers to the same question
&lt;/h2&gt;

&lt;p&gt;I've been building app platforms since 2011, and I've watched the answer to "do I need an app?" change twice.&lt;/p&gt;

&lt;p&gt;The first answer was yes, no discussion. Between 2008 and 2014, being on the home screen meant existing. An icon on the phone was worth a shop window downtown. That rush is what gave birth to GoodBarber, and to dozens of tools like it.&lt;/p&gt;

&lt;p&gt;Then the web took back control. &lt;a href="https://www.goodbarber.com/blog/ios-opens-its-doors-to-progressive-web-apps-a898/" rel="noopener noreferrer"&gt;Progressive Web Apps&lt;/a&gt;, Google pushing the browser as a platform, and a decade of "you don't need an app, a website is enough". The argument was solid. So solid that we added a PWA engine next to our native engines, in 2017, and we still sell it. One project description, three outputs: Swift, Kotlin, and the web. When I say the app had become optional, I say it as someone who built the option.&lt;/p&gt;

&lt;p&gt;The third answer is taking shape this year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed this year
&lt;/h2&gt;

&lt;p&gt;At WWDC in June, Apple made &lt;a href="https://developer.apple.com/videos/play/wwdc2026/343/" rel="noopener noreferrer"&gt;App Intents&lt;/a&gt; the door through which Siri gets into third-party apps, and deprecated the old mechanism, SiriKit. iOS 27, released on September 14, launched a new Siri meant to act and search inside apps from a single sentence. It's early days. &lt;a href="https://www.apple.com/newsroom/2026/09/siri-ai-a-profoundly-more-capable-and-personal-assistant-is-here/" rel="noopener noreferrer"&gt;According to Apple&lt;/a&gt;, it's in beta and English first. It isn't available on iPhones in the European Union, and actions in third-party apps are coming "soon". Android is heading the same way with &lt;a href="https://developer.android.com/blog/posts/the-intelligent-os-making-ai-agents-more-helpful-for-android-apps" rel="noopener noreferrer"&gt;AppFunctions&lt;/a&gt;, shipped in Android 16 and still in beta with Gemini. Dominique covers the mechanism, so I won't go over it again.&lt;/p&gt;

&lt;p&gt;The door itself isn't new: App Intents have existed since 2022. What changes this year is that Apple makes it the standard way in, and that the assistant will walk through it on its own, from a sentence.&lt;/p&gt;

&lt;p&gt;For the question of this article, one thing matters: both mechanisms are native development kits, built into an app's binary, and a web page can't register with them. There is work on the web side, &lt;a href="https://developer.chrome.com/blog/webmcp-epp" rel="noopener noreferrer"&gt;WebMCP&lt;/a&gt;, but it targets agents running in the browser, on an open page. The phone's assistant only looks at what's installed. I'm not saying anything about adoption, which nobody knows, or about when everyone will get access. I'm saying the entry ticket is defined, by both platforms, the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this isn't 2008
&lt;/h2&gt;

&lt;p&gt;It's tempting to read this as a step back.&lt;/p&gt;

&lt;p&gt;In 2008, having an app was about capturing attention: an icon on the home screen, notifications. The app was a shop window. Tomorrow, having an app will be about being present in the assistant: when someone says "get me a seat for Saturday's concert", it's your app or someone else's that answers. The first was won with design and marketing. The second is won by describing your app, what it does as well as what it holds, precisely enough for a machine to use it.&lt;/p&gt;

&lt;p&gt;There's a condition, and Dominique puts it himself on his list of what would prove him wrong: the assistant only calls what's installed. To be present in the assistant, you first have to be installed, and that's exactly the 2008 problem: the shop window is still the prerequisite.&lt;/p&gt;

&lt;p&gt;This isn't the story &lt;a href="https://dev.to/goodbarber/everyone-is-going-back-to-native-we-never-left-and-the-bet-paid-in-a-way-we-didnt-expect-58lp"&gt;I told in September&lt;/a&gt; either. There, I was talking about the cost of building: coding agents make it cheap to build the same app three times. Here, it's about distribution: who gets to be in the assistant. Two different mechanisms, pushing in the same direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, do you need an app in 2026?
&lt;/h2&gt;

&lt;p&gt;The question "do I need an app?" has had one answer per era. Yes, to be seen. No, a website is enough. And now: if you want to exist, one way or another, in the phone's assistant, yes.&lt;/p&gt;

&lt;p&gt;First, to act. Booking a class, ordering, tracking a delivery, moving an appointment: these are exactly the sentences people will say to the assistant, and it can only carry them out in a native app that has declared those actions.&lt;/p&gt;

&lt;p&gt;But also to be found. The assistant searches as much as it acts. When someone asks it to "find me a concert in Ajaccio next weekend", it will look in the content that installed apps have described to it: Apple &lt;a href="https://developer.apple.com/videos/play/wwdc2026/240/" rel="noopener noreferrer"&gt;explicitly plans&lt;/a&gt; for Siri to answer questions about an app's content. It's already what apps built on GoodBarber do when you ask Siri "what's new?", from iOS 17, without their owners having to touch anything. The rollout happens in stages, and my colleague João Aleixo &lt;a href="https://www.goodbarber.com/blog/siri-actions-the-mcp-of-your-ios-app-a1616/" rel="noopener noreferrer"&gt;explains how on our blog&lt;/a&gt;. A cultural events calendar or a local news outlet has as much to gain as a booking app.&lt;/p&gt;

&lt;p&gt;And the assistant doesn't only live in the phone. In the car, few apps get a place on the CarPlay screen: Apple reserves it for a few categories, navigation or audio for instance, and rebuilding an interface for the dashboard rarely makes sense. But Siri is in the car, and an app that has declared its actions can answer there by voice.&lt;/p&gt;

&lt;p&gt;The website keeps its role: it's what people find on Google, and it's what welcomes the visitor who comes once and won't install anything. We still sell PWAs, and many projects will have both: the website for Google, the app for the assistant.&lt;/p&gt;

&lt;p&gt;It isn't urgent. If your users are in Europe, the new Siri hasn't reached them yet, and I don't know when it will. But the entry ticket is already set, and it's a native app.&lt;/p&gt;

&lt;p&gt;The web had made the app optional. The assistant is making it mandatory for anyone who wants to be within earshot.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Mathieu Poli, Head of Frontend Engineering @ &lt;a href="https://goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;. I teach and write about frontend engineering, product design, and AI, and everything that happens when the three meet.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>mcp</category>
      <category>mobile</category>
      <category>ios</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>Our tools are built for humans, not for agents</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Wed, 23 Sep 2026 07:20:37 +0000</pubDate>
      <link>https://dev.to/goodbarber/our-tools-are-built-for-humans-not-for-agents-5f34</link>
      <guid>https://dev.to/goodbarber/our-tools-are-built-for-humans-not-for-agents-5f34</guid>
      <description>&lt;p&gt;You don’t need to believe AI thinks to anthropomorphize it. Handing it our tools is enough. GitHub, the pull request, code review, Scrum, the sprint, the standup, everything that organizes the work of a development team was designed to coordinate humans, and since agents started writing code, we’ve been handing all of it to them as is.&lt;/p&gt;

&lt;p&gt;The frontend team I work in, at GoodBarber, started out adapting that way, then realized fairly quickly it was a mistake and changed course. What I take from it, personally, is that the tooling has to be redefined for machines, not adapted from ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our tools are tools for humans
&lt;/h2&gt;

&lt;p&gt;The standup exists so that people working side by side don’t each drift off in their own direction. The sprint bounds time because we always overestimate what we’ll ship in two weeks. The pull request keeps a record of a choice its author will have forgotten in a month, and code review puts a second pair of eyes where the first no longer sees anything.&lt;/p&gt;

&lt;p&gt;None of these tools is about code. They compensate for the flaws of people, and they were tuned, over twenty-five years, to the speed at which people work. &lt;a href="https://medium.com/@lvanderbijl/built-for-humans-how-agile-process-change-in-an-ai-world-3d34ec93b528" rel="noopener noreferrer"&gt;A Medium post titled “Built for Humans”&lt;/a&gt;, published in June 2026, says it in one sentence: these processes were calibrated on us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anthropomorphism, at industry scale
&lt;/h2&gt;

&lt;p&gt;When AI coding agents arrived, we built nothing for them. An agent opens a branch, pushes commits, writes its pull request, answers the reviewer’s comments, and nobody found that strange.&lt;/p&gt;

&lt;p&gt;A study from NAIST, published on February 19, 2026, on &lt;a href="https://arxiv.org/abs/2602.17084" rel="noopener noreferrer"&gt;33,596 pull requests opened by five agents&lt;/a&gt;, shows how far it has gone: depending on how the agent writes its description, humans answer it faster or slower and accept its code more or less often. We are grading a machine’s manners on a form written for colleagues.&lt;/p&gt;

&lt;p&gt;In the other direction, &lt;a href="https://webcamp.stanford.edu/session/agile-for-agents-managing-robots-the-way-we-manage-humans" rel="noopener noreferrer"&gt;a session at Stanford on April 30, 2026&lt;/a&gt; proposed managing agents with Scrum and Kanban, standups included, to give them the memory and coordination they lack.&lt;/p&gt;

&lt;p&gt;In July 2026, Kyle Galbraith of Depot wrote that &lt;a href="https://depot.dev/blog/github-is-the-wrong-shape-for-this-new-world" rel="noopener noreferrer"&gt;GitHub is the wrong shape&lt;/a&gt; for this new world. His subject was infrastructure. I replied that the problem looked wider to me: Scrum, PRs, sprints all coordinate humans, and we keep anthropomorphizing AI by handing it our rituals.&lt;/p&gt;

&lt;p&gt;Anthropomorphizing, here, means giving the machine a process designed for someone who forgets and gets tired, without having checked what that process was protecting us from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do pull requests work for AI agents?
&lt;/h2&gt;

&lt;p&gt;Yes, they work, and that’s the trap. An agent knows how to open a PR, describe it and answer a comment. The ritual accepts it without blinking, so we keep the ritual and try to adapt inside it.&lt;/p&gt;

&lt;p&gt;That’s what we did at GoodBarber. Before agents, the pull request was already our bottleneck. Code waited for a reviewer longer than it had taken to write. With agents, the number of pull requests exploded, our reading capacity stayed the same, and review delays started being counted in weeks. We spent our days reading code written by a machine, never catching up with the queue.&lt;/p&gt;

&lt;p&gt;The Faros AI report of April 2026, on 22,000 developers, counts&lt;a href="https://www.faros.ai/blog/ai-acceleration-whiplash-takeaways" rel="noopener noreferrer"&gt; 67.4% more pull requests to handle per developer per day&lt;/a&gt;, so there is nothing local about it.&lt;/p&gt;

&lt;p&gt;Reading all that code, I understood that review rested on an assumption we had never had to state: the author will be there tomorrow. They will remember why they made that choice, and they will own the bug if there is one. The whole conversation around a PR rests on that. An agent closes its session and its context is gone.&lt;/p&gt;

&lt;p&gt;A January 2026 study of 33,707 agent-authored pull requests, &lt;a href="https://addyosmani.com/blog/agentic-code-review/" rel="noopener noreferrer"&gt;picked up by Addy Osmani&lt;/a&gt;, sees it in the data: agents merge small, well-scoped changes fast, and disappear the moment they get subjective feedback.&lt;br&gt;
The pull request assumes an author who will come back. The agent doesn’t come back.&lt;/p&gt;

&lt;p&gt;We didn’t manage to adapt review. We removed it. In the spring of 2026 it stopped being mandatory on more and more pull requests, starting with the changes we knew how to undo, the line I&lt;a href="https://dev.to/goodbarber/trust-is-the-net-not-the-eyes-can-you-ship-ai-built-software-you-cant-review-1gfm"&gt; described in my piece on the net&lt;/a&gt;. As of September 2026, it only remains on changes rated critical, the ones whose release is risky. Everything else goes through a pull request loop between agents, and ships without a human having read the diff.&lt;/p&gt;

&lt;p&gt;The first weeks were stressful, I won’t pretend otherwise. But the productivity gain is such that we can afford to handle the misses, provided we assessed beforehand that the risk stayed contained.&lt;/p&gt;

&lt;p&gt;Scrum went the same way, only more abruptly. A two-week sprint is tuned to one person’s throughput, and once execution moves to agents, the sprint is empty long before it ends. The “Built for Humans” post &lt;a href="https://medium.com/@lvanderbijl/built-for-humans-how-agile-process-change-in-an-ai-world-3d34ec93b528" rel="noopener noreferrer"&gt;tells it&lt;/a&gt; as a scene: two weeks of work finished by Tuesday. What limits the team now is what it can specify and verify, and Scrum has no tool to measure that.&lt;/p&gt;

&lt;p&gt;At the September 2026 rentrée, we used the calendar to drop our human-designed methods all at once. We’re trying other ways of working right now, and I don’t have a method to recommend yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a tool for machines needs
&lt;/h2&gt;

&lt;p&gt;What’s missing is the tooling that comes next. Bryan Finster published in July 2026 the experiment that gave me my diagnostic. He had agents run the same tasks with and without our disciplines: TDD, pair programming, separating author and reviewer. &lt;a href="https://bryanfinster.substack.com/p/agentic-workflows-do-agents-work" rel="noopener noreferrer"&gt;His result&lt;/a&gt;: refactoring improves the code, writing the test first changes nothing. “Refactoring is the mechanism. Test-first ordering is the ceremony.”&lt;/p&gt;

&lt;p&gt;The mechanism serves the machine. The ceremony served the human who had to force himself to think before writing. The ceremony is the human part of the tool, and it’s exactly what we hand the agent when we anthropomorphize.&lt;/p&gt;

&lt;p&gt;From there, we can say what a tool designed for a machine looks like, because we already know its properties in the negative.&lt;/p&gt;

&lt;p&gt;It asks nobody to be present while the machine works, whereas a ritual rests entirely on presence.&lt;/p&gt;

&lt;p&gt;It draws the line between what the agent executes and what it proposes on the reversibility of the mistake, not on how confident the model sounds.&lt;/p&gt;

&lt;p&gt;It gives the agent a written context it reads before starting, in place of a meeting where someone recites it.&lt;br&gt;
And it verifies at the machine’s production speed, not at a human’s reading speed.&lt;/p&gt;

&lt;p&gt;We had to design such a tool, on a small scale, because we had no choice. When AI writes code for someone who isn’t a developer, inside our product, that person has no pull request, no sprint, no reviewer. There was no ritual to lend it.&lt;/p&gt;

&lt;p&gt;What we wrote looks like none of our meetings: a line drawn on reversibility, and an access surface declared up front and checked server-side, &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 choice my CTO Dominique has written about&lt;/a&gt;. It’s not much, but it’s designed for the machine, and it was easier to find there than in my own team, where we had a ritual within reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tooling has to be rebuilt
&lt;/h2&gt;

&lt;p&gt;My position is that the next generation of development tools has to be defined for machines, not adapted from ours. A faster pull request or an automated standup keep the human shape, and the human shape is the problem.&lt;/p&gt;

&lt;p&gt;We’ll need tools that carry context so nobody has to remember it, that bound damage rather than time, and that verify at the pace the machine produces. I don’t know yet what they will look like. I’m wary of those who already do, they usually sell one.&lt;/p&gt;

&lt;p&gt;It’s the same conclusion I reached &lt;a href="https://dev.to/goodbarber/ai-didnt-kill-the-framework-it-made-it-more-valuable-3n5p"&gt;about frameworks&lt;/a&gt;: when producing gets cheap, what holds value is what remains underneath, and for a team that’s the tooling.&lt;/p&gt;

&lt;p&gt;If you’re adapting your team to AI, look at what you’re handing it. If it’s a pull request, a sprint and a standup, you’re not adapting to AI. You’re teaching it to pretend to be one of your colleagues.&lt;/p&gt;

&lt;p&gt;Our tools compensate for our flaws. Before handing one to a machine, ask whether it has the flaw.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mathieu Poli — Head of Frontend Engineering @ GoodBarber. I teach and write about frontend engineering, product design, and AI, and everything that happens when the three meet.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;@hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/photos/3qZR6AQx_8w" rel="noopener noreferrer"&gt;nikohoshi&lt;/a&gt; on Unsplash.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>productivity</category>
      <category>career</category>
    </item>
    <item>
      <title>Listing a remote MCP server in every directory: what each one actually checks (September 2026)</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 17 Sep 2026 11:52:00 +0000</pubDate>
      <link>https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con</link>
      <guid>https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con</guid>
      <description>&lt;p&gt;The Save button did nothing. We opened the network panel. The request had returned HTTP 200.&lt;/p&gt;

&lt;p&gt;Inside the response: a validation error on a field the form did not show.&lt;/p&gt;

&lt;p&gt;By then we had spent several days trying to get the same remote MCP server listed across directories. Another directory had published us in about ten minutes. A GitHub list had tested our endpoint and accepted its 401. Elsewhere, our submission was waiting for a human.&lt;/p&gt;

&lt;p&gt;"Get the server listed" had turned into a collection of different jobs.&lt;/p&gt;

&lt;p&gt;One line of context so you know where I stand: I run engineering at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;our MCP server&lt;/a&gt; lets assistants manage the apps through an authenticated connection. It is hosted, it requires OAuth, and there is no public server code for a directory to scan: our public repository holds skills and client configuration. That distinction decided which submission paths worked.&lt;/p&gt;

&lt;p&gt;After &lt;a href="https://dev.to/goodbarber/our-mcp-server-is-now-a-chatgpt-plugin-2pjm"&gt;getting the server into ChatGPT's plugin directory&lt;/a&gt;, we widened the search. What follows happened between September 7 and 14, and every public state was checked again on September 14. The delays are the ones our submissions went through, not a promise from anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The official MCP Registry: publish the identity first
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io/registry/about" rel="noopener noreferrer"&gt;official MCP Registry&lt;/a&gt; accepts metadata for hosted servers, closed-source implementations included, as long as the server is publicly reachable. It authenticates the publisher's namespace and validates the metadata. Beyond that, its metadata is deliberately unopinionated, and curation is left to the aggregators downstream.&lt;/p&gt;

&lt;p&gt;Our entry, &lt;code&gt;dev.goodbarber/goodbarber-public-mcp&lt;/code&gt;, has been there since April 28 (version 1.0.0). On September 7 we published version &lt;code&gt;1.2.1&lt;/code&gt;, with DNS authentication: it declares &lt;code&gt;streamable-http&lt;/code&gt;, the remote endpoint and the product page. On September 14 the &lt;a href="https://registry.modelcontextprotocol.io/v0.1/servers?search=dev.goodbarber&amp;amp;limit=100" rel="noopener noreferrer"&gt;registry API&lt;/a&gt; still marked it active and latest.&lt;/p&gt;

&lt;p&gt;We would do this first again. It gives importers a stable identity and a record to consume, instead of asking each directory to rebuild the product from a README.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://glama.ai/mcp/connectors/dev.goodbarber/goodbarber-public-mcp" rel="noopener noreferrer"&gt;Glama&lt;/a&gt; had already imported a connector entry from that record. We claimed it, and on September 8 it displayed "Ownership verified" and "Healthy". Both were still there on September 14.&lt;/p&gt;

&lt;p&gt;Upstream publication does not tell you when the downstream pages will exist. MCP.Directory says it auto-discovers servers from the official registry, where our entry has been since April. Its submission form promises a review within 24 hours, and its first, required field is a GitHub repository, from which it detects the tools by analyzing the server's implementation. The Knights Who Say Ni wanted a shrubbery; a hosted server with no public code has no repository to give. On September 14, MCP.Directory's &lt;a href="https://mcp.directory/sitemap.xml" rel="noopener noreferrer"&gt;sitemap&lt;/a&gt; still had no GoodBarber URL, six days after a server listing request and five skill submissions. Where those submissions sit, we do not know.&lt;/p&gt;

&lt;h2&gt;
  
  
  awesome-remote-mcp-servers: the CI that accepts a 401
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers" rel="noopener noreferrer"&gt;awesome-remote-mcp-servers&lt;/a&gt; is built for our shape of product. It only lists servers the provider hosts: reachable at a public URL, usable by anyone who can sign up, speaking Streamable HTTP or SSE. Each entry's name links to the product's homepage, not to a GitHub repository, because the endpoint is the thing being listed. The list was created on September 8, and our PR went in the same morning.&lt;/p&gt;

&lt;p&gt;It also gave us something we could test. Its &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/blob/a3bb155f3a2ad0cc159781f919fc78c187b84bec/.github/workflows/check-submission.yml" rel="noopener noreferrer"&gt;submission workflow at the merge commit&lt;/a&gt; probes each endpoint with a real &lt;code&gt;initialize&lt;/code&gt; handshake and sends no credentials. We replayed the check before submitting. Our endpoint answered with an authentication challenge, structurally like this (generic domain):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="ne"&gt;Unauthorized&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow takes a 401 or a 403 as a live endpoint that requires authentication, and reads the &lt;code&gt;WWW-Authenticate&lt;/code&gt; header to choose between the OAuth and API-key markers. Our 401 passed with the OAuth marker. The probe never completes OAuth and never calls a tool: a green check here means the door exists.&lt;/p&gt;

&lt;p&gt;Two more rules matter before you open the PR. Every entry needs a Glama &lt;strong&gt;connector&lt;/strong&gt; badge, and CI checks that the connector exists. And the &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;contribution rules&lt;/a&gt; document a fast track for automated agents: three robot emoji at the end of the PR title. Our PR was prepared by an agent, so we used it. &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/pull/4" rel="noopener noreferrer"&gt;PR #4&lt;/a&gt; was opened at 07:23 UTC, labeled &lt;code&gt;endpoint-ok&lt;/code&gt; and &lt;code&gt;has-connector&lt;/code&gt; by CI nine seconds later, and merged at 13:50 UTC, on September 8.&lt;/p&gt;

&lt;p&gt;Then the bot ran again on the merged PR. It added &lt;code&gt;duplicate&lt;/code&gt; and &lt;code&gt;missing-connector&lt;/code&gt;, removed &lt;code&gt;has-connector&lt;/code&gt;, and asked us to remove the duplicate entry. The duplicate was ours: the check was now reading the list it had just merged. The README on the main branch settled what the labels could not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gemini CLI and GitHub: a crawler versus a review queue
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://geminicli.com/docs/extensions/releasing/" rel="noopener noreferrer"&gt;Gemini CLI gallery instructions&lt;/a&gt; describe a daily crawl of public GitHub repositories carrying the &lt;code&gt;gemini-cli-extension&lt;/code&gt; topic. A &lt;code&gt;gemini-extension.json&lt;/code&gt; manifest at the root of the repository supplies the metadata, and an extension appears in the gallery only if it passes validation. The instructions say there is no issue to file and no email to send.&lt;/p&gt;

&lt;p&gt;We added the topic on September 9. On September 14 the topic and the manifest were in place, and the &lt;a href="https://geminicli.com/extensions/" rel="noopener noreferrer"&gt;gallery page&lt;/a&gt; had no GoodBarber entry. A daily crawl gives no per-repository result, so we cannot tell whether we failed validation or were never picked up.&lt;/p&gt;

&lt;p&gt;GitHub's MCP gallery has another gate. In &lt;a href="https://github.com/github/github-mcp-server/discussions/1257" rel="noopener noreferrer"&gt;discussion #1257&lt;/a&gt;, a GitHub answer explains that onboarding a new server is a manual curation process, and that new registry versions sync once a server has been onboarded. We asked to be included on September 8; on September 14 the expected gallery URL still returned 404. Republishing the registry record would change nothing there.&lt;/p&gt;

&lt;p&gt;Our Claude Code plugin sits in a similar waiting room. Plugins are submitted through a form, and the public &lt;a href="https://github.com/anthropics/claude-plugins-community/blob/main/.claude-plugin/marketplace.json" rel="noopener noreferrer"&gt;community marketplace catalogue&lt;/a&gt; is a read-only mirror, synced nightly from Anthropic's review pipeline, of the plugins that passed automated security scanning and were approved. Ours was submitted on September 7, shown as awaiting review on September 8, and still absent from that catalogue on September 14.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude connectors: ten minutes, then an inventory problem
&lt;/h2&gt;

&lt;p&gt;The Claude &lt;strong&gt;connectors directory&lt;/strong&gt; is a different destination from the Claude Code plugin marketplace. Remote servers are submitted from an organization's admin settings, through a portal that connects to the server. Our &lt;a href="https://claude.ai/directory/424480c2-9956-40d4-8af3-5db873be30ed" rel="noopener noreferrer"&gt;connector listing&lt;/a&gt; appeared about ten minutes after we submitted it on September 8, while the plugin was still waiting. Its tier is &lt;strong&gt;community&lt;/strong&gt;, and that is the word we use.&lt;/p&gt;

&lt;p&gt;Then we read the published inventory and found our own mistake. The directory's &lt;a href="https://api.anthropic.com/api/directory/servers?limit=5000&amp;amp;visibility=commercial,gsuite,gsuite-google&amp;amp;verified_tier=anthropic,partner,community" rel="noopener noreferrer"&gt;public record&lt;/a&gt; listed 120 tool names and left out an entire tool family, the one behind push notifications and analytics, while the description we wrote advertised both. The portal syncs the tool list from the server through the connection made at submission, and our connection did not expose that family. The description is ours to edit from the dashboard; the tool list is not an editable field, and changing it goes through the review team. Either way, the fix is ours.&lt;/p&gt;

&lt;p&gt;For someone following the &lt;a href="https://www.goodbarber.com/connect-claude-app/" rel="noopener noreferrer"&gt;Claude connection guide&lt;/a&gt;, the only question that matters is whether the tools they get cover the task they came for. A directory listing does not change the tools a connection exposes, so the check we owe that reader goes beyond the listing: the published inventory against the description, then a real connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  mcp.so: HTTP 200, application-level failure
&lt;/h2&gt;

&lt;p&gt;The Save button from the opening was on mcp.so. We were updating an existing listing whose transport and overview had gone stale.&lt;/p&gt;

&lt;p&gt;On September 8 the edit request returned HTTP 200, and its body carried application code &lt;code&gt;-1&lt;/code&gt;, an "Invalid submission" message and a validation error under &lt;code&gt;fieldErrors.tagline&lt;/code&gt;. The old tagline was too long. The form had no field to fix it and showed no error.&lt;/p&gt;

&lt;p&gt;We sent the edit again with a shorter tagline, then read the public page, because a save response is a claim, not a result. On September 14 the &lt;a href="https://mcp.so/servers/goodbarber-skills" rel="noopener noreferrer"&gt;listing&lt;/a&gt; showed Streamable HTTP, OAuth and the updated overview.&lt;/p&gt;

&lt;p&gt;If that sounds familiar, it is &lt;a href="https://dev.to/pierrelaurentmedori/your-mcp-write-returned-200-did-the-right-thing-actually-happen-38n0"&gt;the write-safety piece&lt;/a&gt; in miniature, with a web form in place of an agent. The green request in the network panel meant an HTTP exchange had completed, nothing more. Read the body, then read the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP Market: fast, paid, and one button to check
&lt;/h2&gt;

&lt;p&gt;On September 8 we paid $69 for the remote-server "Official" listing option, which is a different product from the GitHub-repository submission. The &lt;a href="https://mcpmarket.com/server/goodbarber" rel="noopener noreferrer"&gt;listing&lt;/a&gt; was live the next morning, inside the advertised 24 hours, with the badge and the categories we asked for.&lt;/p&gt;

&lt;p&gt;Search MCP Market for GoodBarber and a second card shows up next to ours: GoodBarber Skills, filed under Marketing Automation.&lt;/p&gt;

&lt;p&gt;Then we clicked &lt;strong&gt;Try Now&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We had submitted our MCP product page as the destination. The published button led to our app-creation page, with MCP Market campaign parameters added, and still did on September 14. Both pages are ours, and it matters anyway: someone looking for connection instructions lands on a different step of the product journey.&lt;/p&gt;

&lt;p&gt;Paying got the listing published fast. Checking where it sends people stayed our job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smithery and skills.sh: read what the signal measures
&lt;/h2&gt;

&lt;p&gt;On Smithery, verification happens in the server's settings, through what its publishing guide calls an automatic official-vendor verification checklist. Our domain and backlink proofs passed their recheck on September 8. The public data embedded in the &lt;a href="https://smithery.ai/servers/goodbarber/goodbarber-public-mcp" rel="noopener noreferrer"&gt;listing page&lt;/a&gt; said &lt;code&gt;verified: false&lt;/code&gt; afterwards, and still did on September 14. The guide names the checklist without listing its items, and nothing we could see explains the step between two passed proofs and a false flag. We write down both states rather than calling the whole thing verified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.sh/goodbarber/goodbarber-skills" rel="noopener noreferrer"&gt;skills.sh&lt;/a&gt; showed our 44 skills on September 14, with installation counts. There is nothing to submit there: skills appear through the anonymous install telemetry of its CLI, when people run &lt;code&gt;npx skills add&lt;/code&gt;. Those skills are instruction packages for using the server, and the counter measures installs: not a completed workflow, and not an OAuth flow that works in every client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The September 14 snapshot
&lt;/h2&gt;

&lt;p&gt;Every state below was checked on September 14, 2026; submission and publication dates are the real ones. Read "pending" as pending, and "absent" as unexplained.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Directory or catalogue&lt;/th&gt;
&lt;th&gt;Admission mechanism&lt;/th&gt;
&lt;th&gt;Observed delay&lt;/th&gt;
&lt;th&gt;State on September 14&lt;/th&gt;
&lt;th&gt;Pitfall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Official MCP Registry&lt;/td&gt;
&lt;td&gt;Namespace authentication, metadata validation&lt;/td&gt;
&lt;td&gt;Entry since April 28; 1.2.1 on September 7&lt;/td&gt;
&lt;td&gt;Version 1.2.1 active and latest&lt;/td&gt;
&lt;td&gt;Curation is left to aggregators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glama&lt;/td&gt;
&lt;td&gt;Registry import, ownership claim, health checks&lt;/td&gt;
&lt;td&gt;Initial import not timed&lt;/td&gt;
&lt;td&gt;Ownership verified; Healthy&lt;/td&gt;
&lt;td&gt;Ownership and health are separate signals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers" rel="noopener noreferrer"&gt;awesome-remote-mcp-servers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Endpoint CI without credentials, Glama connector badge, PR&lt;/td&gt;
&lt;td&gt;Same day, September 8&lt;/td&gt;
&lt;td&gt;PR #4 merged&lt;/td&gt;
&lt;td&gt;The probe stops at the 401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP.Directory&lt;/td&gt;
&lt;td&gt;Registry auto-discovery; GitHub-based form with a 24-hour review promise&lt;/td&gt;
&lt;td&gt;September 8 to 14, unresolved&lt;/td&gt;
&lt;td&gt;No GoodBarber URL in sitemap&lt;/td&gt;
&lt;td&gt;The form expects a GitHub repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini CLI gallery&lt;/td&gt;
&lt;td&gt;Topic crawl, root manifest, validation&lt;/td&gt;
&lt;td&gt;September 9 to 14, unresolved&lt;/td&gt;
&lt;td&gt;No entry on the gallery page&lt;/td&gt;
&lt;td&gt;A daily crawl gives no per-repo result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub MCP gallery&lt;/td&gt;
&lt;td&gt;Manual curation, then version sync&lt;/td&gt;
&lt;td&gt;September 8 to 14, unresolved&lt;/td&gt;
&lt;td&gt;Expected URL returns 404&lt;/td&gt;
&lt;td&gt;Registry publication does not grant admission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code community marketplace&lt;/td&gt;
&lt;td&gt;Submission form, security scan, approval, nightly mirror&lt;/td&gt;
&lt;td&gt;September 7 to 14, unpublished&lt;/td&gt;
&lt;td&gt;Absent from public catalogue&lt;/td&gt;
&lt;td&gt;Separate from the connectors directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude connectors directory&lt;/td&gt;
&lt;td&gt;Admin portal; tools synced from the connected server&lt;/td&gt;
&lt;td&gt;About 10 minutes, September 8&lt;/td&gt;
&lt;td&gt;Community listing; 120 declared tools, no push or analytics&lt;/td&gt;
&lt;td&gt;The synced tools did not match our description&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mcp.so&lt;/td&gt;
&lt;td&gt;Authenticated form and API validation&lt;/td&gt;
&lt;td&gt;Update completed September 8&lt;/td&gt;
&lt;td&gt;Corrected transport and overview live&lt;/td&gt;
&lt;td&gt;HTTP 200 carried a validation failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP Market&lt;/td&gt;
&lt;td&gt;Paid remote-server submission&lt;/td&gt;
&lt;td&gt;Live the next morning, September 9&lt;/td&gt;
&lt;td&gt;Official listing live; a GoodBarber Skills card also listed&lt;/td&gt;
&lt;td&gt;Try Now destination changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smithery&lt;/td&gt;
&lt;td&gt;Official-vendor verification checklist&lt;/td&gt;
&lt;td&gt;Proofs passed September 8&lt;/td&gt;
&lt;td&gt;Public data still says &lt;code&gt;verified: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Passed proofs did not flip the public flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skills.sh&lt;/td&gt;
&lt;td&gt;No submission; CLI install telemetry&lt;/td&gt;
&lt;td&gt;First install not timed&lt;/td&gt;
&lt;td&gt;44 skills listed&lt;/td&gt;
&lt;td&gt;Installs are not workflow success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/docker/mcp-registry/pull/4968" rel="noopener noreferrer"&gt;Docker MCP Catalog&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Remote-server PR, Docker team review, test credentials by form&lt;/td&gt;
&lt;td&gt;Open since September 8&lt;/td&gt;
&lt;td&gt;PR #4968 open; 0 comments&lt;/td&gt;
&lt;td&gt;Review outcome unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/VoltAgent/awesome-agent-skills/pull/1031" rel="noopener noreferrer"&gt;VoltAgent skills list&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Skills-list PR; rules require real community usage&lt;/td&gt;
&lt;td&gt;September 8 to 14&lt;/td&gt;
&lt;td&gt;PR #1031 closed without merge or comment&lt;/td&gt;
&lt;td&gt;Adoption comes before the listing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/BehiSecc/awesome-claude-skills/pull/689" rel="noopener noreferrer"&gt;BehiSecc skills list&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Skills-list PR&lt;/td&gt;
&lt;td&gt;Open since September 8&lt;/td&gt;
&lt;td&gt;PR #689 open; 0 comments&lt;/td&gt;
&lt;td&gt;Skills listings have their own review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/cline/mcp-marketplace/issues/2492" rel="noopener noreferrer"&gt;Cline marketplace&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Submission issue; review of adoption, credibility, maturity, security&lt;/td&gt;
&lt;td&gt;Open since September 9&lt;/td&gt;
&lt;td&gt;Issue #2492 open; 0 comments&lt;/td&gt;
&lt;td&gt;Review weighs community adoption&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The order we would follow next time
&lt;/h2&gt;

&lt;p&gt;Start with the official registry, and with one description, one endpoint, one product page and one documentation URL, the same everywhere. Separate the hosted server from the skills package before choosing routes: some submission forms start with a GitHub repository, and a hosted server has nothing to put in that field.&lt;/p&gt;

&lt;p&gt;Then read each gate before you submit. Replay public CI checks. If an agent prepares the PR, use the documented agent fast track. For a crawler, check the topic and the manifest. For a curated catalogue, read the acceptance criteria first, since some weigh community adoption, then keep the submission reference and wait for evidence of admission.&lt;/p&gt;

&lt;p&gt;After publication, read what went live: the tool inventory, the authentication metadata, every destination link. Keep dates next to results and recheck weekly. The merged PR, the badge and the live page can tell three different stories.&lt;/p&gt;

&lt;p&gt;All of it points to one page, &lt;a href="https://www.goodbarber.com/mcp-complete-guide/" rel="noopener noreferrer"&gt;the connection guide&lt;/a&gt;. If you are connecting GoodBarber to an assistant, start there.&lt;/p&gt;

&lt;p&gt;One more thing: that was a dense week, and these procedures move fast. What you read above comes from each directory's own documentation where it has one, and from what we saw on the forms otherwise, checked on September 14. If you run one of these directories and something here is wrong, or already out of date, tell us in the comments.&lt;/p&gt;

&lt;p&gt;Which check caught a mismatch in your own listing: the response body, the tool inventory, or the link after publication? Genuinely curious.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>opensource</category>
      <category>devrel</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>"Too many levels of symbolic links" with no symlink in sight: autofs, a bind mount and a cron container</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 15 Sep 2026 11:54:39 +0000</pubDate>
      <link>https://dev.to/goodbarber/too-many-levels-of-symbolic-links-with-no-symlink-in-sight-autofs-a-bind-mount-and-a-cron-20nf</link>
      <guid>https://dev.to/goodbarber/too-many-levels-of-symbolic-links-with-no-symlink-in-sight-autofs-a-bind-mount-and-a-cron-20nf</guid>
      <description>&lt;p&gt;In September, our PHP crons started failing with an error that sounded specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Too many levels of symbolic links
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is ELOOP, the error a symlink loop gives you, so the first move was to look for a loop on &lt;code&gt;/srv/apps/app-a/task.php&lt;/code&gt;. There was no symlink on that path. Nothing to untangle.&lt;/p&gt;

&lt;p&gt;Paths and component names below are generic examples, not ours.&lt;/p&gt;

&lt;p&gt;By the time we looked again, on September 10, the file was readable. The autofs service had just restarted, and the same file answered on &lt;code&gt;/opt/code/app-a/task.php&lt;/code&gt; and on &lt;code&gt;/srv/apps/app-a/task.php&lt;/code&gt;. That left a question the successful &lt;code&gt;ls&lt;/code&gt; could not answer: what had to work for the second path to reach the first?&lt;/p&gt;

&lt;p&gt;For context: I run engineering at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and these crons run its background jobs. The short version of what follows: the code was already bind-mounted into the container, the paths the crons used still went through autofs, so we mounted the code directly at those paths and took them out of the automount map. If ELOOP sends you hunting for symlinks that are not there, look at your automounts, and at what the container's mount namespace can actually see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The autofs layer inside Docker
&lt;/h2&gt;

&lt;p&gt;The cron container's Compose configuration already mounted the application code from the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;host /srv/code/app-a -&amp;gt; container /opt/code/app-a
host /srv/code/app-b -&amp;gt; container /opt/code/app-b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application expects &lt;code&gt;/srv/apps/app-a&lt;/code&gt; and &lt;code&gt;/srv/apps/app-b&lt;/code&gt;. The image supplied those two paths through an autofs direct map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/auto.master
/- /etc/auto.apps --ghost,--timeout=30

# Two entries in /etc/auto.apps
/srv/apps/app-a -fstype=bind :/opt/code/app-a
/srv/apps/app-b -fstype=bind :/opt/code/app-b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any access to either path could trigger the automounter. The other entries in that map were NFS shares, managed by the same daemon.&lt;/p&gt;

&lt;p&gt;Two layers of mounting for code that was already local. Opening a PHP file depended on the service that manages our network mounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an automount can return ELOOP
&lt;/h2&gt;

&lt;p&gt;The error name is narrower than the mechanism behind it. In Linux v6.1, &lt;a href="https://github.com/torvalds/linux/blob/v6.1/fs/namei.c#L1340-L1363" rel="noopener noreferrer"&gt;&lt;code&gt;follow_automount()&lt;/code&gt;&lt;/a&gt; increments &lt;code&gt;nd-&amp;gt;total_link_count&lt;/code&gt;, the same counter symlink traversal spends, and returns &lt;code&gt;-ELOOP&lt;/code&gt; once it reaches &lt;code&gt;MAXSYMLINKS&lt;/code&gt;, which is 40. During a path lookup, every automount crossed counts against the symlink limit. No symlink required.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.kernel.org/filesystems/autofs.html#autofs-name-spaces-and-shared-mounts" rel="noopener noreferrer"&gt;autofs documentation&lt;/a&gt; describes the other half. When an autofs filesystem is visible in several places and the mounts created by its daemon do not propagate to the others, access from those other places "will likely result in the ELOOP error". The caller keeps meeting a trigger and never sees the mount it is waiting for.&lt;/p&gt;

&lt;p&gt;That is how you get ELOOP without a symlink. Which of the two our incident took, we cannot say: the investigation tied the failure to an NFS incident and an unhealthy automounter, and we did not capture a kernel trace. What the configuration did show is the dependency itself, and it had no reason to exist, whatever upset the daemon first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix stayed in configuration
&lt;/h2&gt;

&lt;p&gt;On September 10 we added direct Docker binds at the paths the crons actually use. The relevant Compose fragment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bind&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/code/app-a&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/apps/app-a&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bind&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/code/app-b&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/apps/app-b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also gave the cron container its own copy of the automount map, with those two entries removed. It takes both changes: leave the keys in the map and autofs installs its triggers over the direct mounts again.&lt;/p&gt;

&lt;p&gt;One detail of our image: the map path, shown here as &lt;code&gt;/etc/auto.apps&lt;/code&gt;, is a symlink to the real map file. The Compose override mounts the cron-specific map over that target. The web containers keep the map they had.&lt;/p&gt;

&lt;p&gt;The application teams maintain and deploy that code. This was an operations fix, so it stayed in the mount configuration and the application paths did not move. No PHP patch, no local checkout drifting away from what the application teams deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the dependency is gone
&lt;/h2&gt;

&lt;p&gt;Roy from &lt;em&gt;The IT Crowd&lt;/em&gt; has a suggestion: "Have you tried turning it off and on again?" We already had a readable file after a restart. The useful test was whether it still needed the automounter.&lt;/p&gt;

&lt;p&gt;Inside the cron container, check each mount on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;findmnt &lt;span class="nt"&gt;-o&lt;/span&gt; TARGET,SOURCE,FSTYPE /srv/apps/app-a
findmnt &lt;span class="nt"&gt;-o&lt;/span&gt; TARGET,SOURCE,FSTYPE /srv/apps/app-b
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s1"&gt;'^/srv/apps/app-(a|b)[[:space:]]'&lt;/span&gt; /etc/auto.apps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both mounts should resolve to the host-backed code, with no autofs trigger underneath. The count should be &lt;code&gt;0&lt;/code&gt;, and &lt;code&gt;grep&lt;/code&gt; exits with status 1 when nothing matches. If mounts are stacked, read &lt;code&gt;/proc/self/mountinfo&lt;/code&gt; as well: the filesystem on top hides what sits below it.&lt;/p&gt;

&lt;p&gt;Then check the cron runs after the deployment. The jobs have to actually run, and their new logs have to be free of ELOOP. Silence from a job that never started proves nothing.&lt;/p&gt;

&lt;p&gt;The deployment was confirmed working on September 10. One check we have not run yet: in a maintenance window, with the NFS-dependent jobs paused, stop autofs inside the cron container, read the PHP entry point, then restart autofs and confirm it is active. The direct bind should stay readable. That tests the dependency removal, not an NFS hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintenance we kept
&lt;/h2&gt;

&lt;p&gt;We now have two maps, and any change to their shared NFS entries has to land in both. Removing a runtime dependency left us a configuration chore.&lt;/p&gt;

&lt;p&gt;The code was local all along. We changed how the crons reach it, and opening a local PHP file no longer needs the automounter to cooperate.&lt;/p&gt;

&lt;p&gt;Have you met ELOOP with no symlink in sight? What was underneath on your side: autofs, a bind mount, something stranger? Genuinely curious.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>docker</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Everyone is going back to native. We never left, and the bet paid in a way we didn't expect</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:45:22 +0000</pubDate>
      <link>https://dev.to/goodbarber/everyone-is-going-back-to-native-we-never-left-and-the-bet-paid-in-a-way-we-didnt-expect-58lp</link>
      <guid>https://dev.to/goodbarber/everyone-is-going-back-to-native-we-never-left-and-the-bet-paid-in-a-way-we-didnt-expect-58lp</guid>
      <description>&lt;p&gt;You've probably read it by now: Shopify is moving all of its mobile apps back to Swift and Kotlin, six years after going all-in on React Native. &lt;a href="https://shopify.engineering/back-to-native" rel="noopener noreferrer"&gt;In their own words&lt;/a&gt;, "building the same feature in Swift and Kotlin no longer carries the cost it used to," because coding agents now do enough of the work in between. We never left, and I want to be precise about why. We bet on native in 2011 for one reason: iOS and Android would outlive every layer stacked on top of them. Every layer since has been temporary, which we expected. What ended the detour was AI, which we didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  We bet on native because it was the layer that would last
&lt;/h2&gt;

&lt;p&gt;When people ask why an app builder compiles Swift and Kotlin instead of shipping one cross-platform codebase, they expect an answer about quality, and the honest answer is about time. In 2011 we had to decide what to build our engines on, knowing we'd carry that choice for a decade or more, so the question we asked was which layer would still be there in ten years.&lt;br&gt;
The platforms would. Apple and Google were not going to abandon their operating systems, their toolchains or their SDKs. Everything in between, the frameworks promising to spare you the platforms, had to earn a decade of existence, and nothing about them suggested they would. So we invested in the layer that was certain to remain, and we treated everything stacked on it as temporary, however popular it was that year. That's the whole bet. It cost us more per feature, and it bought us a foundation we have never had to rewrite because a dependency died underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every layer since has been temporary
&lt;/h2&gt;

&lt;p&gt;The waves came on schedule, roughly one every few years, &lt;a href="https://dev.to/goodbarber/the-freedom-you-dont-want-why-infinite-customization-is-a-tax-not-a-feature-2og6"&gt;and each was presented to us as our replacement&lt;/a&gt;. 2011: PhoneGap, bought by Adobe that year, and the first serious promise of one web codebase for every platform. 2015: React Native. 2016: Xamarin, bought by Microsoft, and the promise of C# everywhere. 2018: Flutter 1.0. We evaluated two of them seriously, React Native a couple of years after it shipped and Flutter once it had matured, and both times the same question decided it: what's the lifespan of this layer, compared with the lifespan of what's underneath?&lt;br&gt;
Then the answers arrived. &lt;a href="https://cordova.apache.org/announcements/2020/08/14/goodbye-phonegap.html" rel="noopener noreferrer"&gt;Adobe discontinued PhoneGap in 2020&lt;/a&gt;. &lt;a href="https://dotnet.microsoft.com/en-us/platform/support/policy/xamarin" rel="noopener noreferrer"&gt;Microsoft ended Xamarin support in 2024&lt;/a&gt;. And this year Shopify, which had bet on the most successful of those layers, announced its way back to the platforms. The intermediate layer kept changing names while iOS and Android kept theirs. None of that makes the people who chose those frameworks wrong: for a company shipping one app, avoiding a second codebase was a rational bet, and I'd have made it in their seat. It's just a bet on a shorter horizon than ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we could afford a bet nobody else could
&lt;/h2&gt;

&lt;p&gt;What made our choice possible is simpler than vision, and I'd rather state it plainly. An app builder doesn't build one app; it builds engines that render thousands. At GoodBarber, our iOS engine is Swift, our Android engine is Kotlin, and a third engine produces a Progressive Web App; &lt;a href="https://dev.to/goodbarber/the-rendering-engine-how-a-no-code-project-becomes-a-smooth-native-app-nd4"&gt;a single project description feeds all three&lt;/a&gt;. We build three times, but we build three times once, and every app the platform renders amortizes those three engines a little more. The duplication cost that pushed the industry toward hybrid, divided by thousands of apps, was simply our business model. The reason to leave native never existed on our side of the fence.&lt;br&gt;
The bill is real, though. Three engines mean three expertises to hire and three implementations to keep in step, and the parity work between platforms is a permanent line in our budget. It's payable for one reason only: it's paid once, and divided by thousands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bet bought
&lt;/h2&gt;

&lt;p&gt;Most of what native bought us lives below what users can name. The sensory layer: haptic feedback, parallax, motion, a tab bar that floats, scrolling with the system's exact physics, transitions that belong to the OS. A media player that keeps playing when the screen goes dark and follows the user from screen to screen. Local notifications that fire from the app binary itself when someone enters a geofence or walks near a beacon, with no server involved, which only exists when the app is real native code running with the operating system's own permissions. Each platform's own idioms, because three engines can be right in three worlds instead of average in two: the share sheet is Apple's on iOS and Android's on Android. And when Apple ships a new primitive at WWDC, we can adopt it as soon as it lands, with no framework in between that has to wrap it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  It ended, but not the way we pictured
&lt;/h2&gt;

&lt;p&gt;We expected the intermediate layers to wear out one by one, and we expected time to settle the bet. What settled it was AI, and from a direction we hadn't imagined. Instead of waiting for the hybrid frameworks to fade, it removed the only reason anyone had to use them. The whole case for a shared codebase was the cost of building twice, and models and agents now carry enough of the translation, the testing and the review across platforms that duplication stopped deciding anything. A team shipping one app can land today where we've been since 2011, on the platform's own languages with the platform's own tooling. Two roads to the same place, fifteen years apart: agents for them, architecture for us.&lt;br&gt;
For us, AI lands on top of that structure &lt;a href="https://dev.to/goodbarber/ai-didnt-kill-the-framework-it-made-it-more-valuable-3n5p"&gt;rather than instead of it&lt;/a&gt;. Generated sections live inside apps whose engines were already native, and the model works with first-party toolchains, &lt;a href="https://dev.to/goodbarber/trust-is-the-net-not-the-eyes-can-you-ship-ai-built-software-you-cant-review-1gfm"&gt;where it can test its own work fastest&lt;/a&gt;. We bet that the platforms would outlast everything built on top of them. They did. We just thought the proof would take longer, and come from somewhere else. The bet paid, not the way we pictured, which is usually how bets pay.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mathieu Poli — Head of Frontend Engineering @ GoodBarber. &lt;br&gt;
I teach and write about frontend engineering, product design, and AI — and everything that happens when the three meet.&lt;br&gt;
X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;@hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>ios</category>
      <category>android</category>
      <category>nocode</category>
    </item>
    <item>
      <title>Fourteen years of blog posts, seven languages, one laptop: an open-weight model did our hreflang backfill</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:37:50 +0000</pubDate>
      <link>https://dev.to/goodbarber/fourteen-years-of-blog-posts-seven-languages-one-laptop-an-open-weight-model-did-our-hreflang-kpo</link>
      <guid>https://dev.to/goodbarber/fourteen-years-of-blog-posts-seven-languages-one-laptop-an-open-weight-model-did-our-hreflang-kpo</guid>
      <description>&lt;p&gt;In May I had 5,892 blog posts spread over seven hosts, the oldest dated November 7, 2011, and not one field anywhere that said which post was the translation of which. Same company, same blog: French on fr.goodbarber.com, English on www, then es, it, pt, de and nl on their own subdomains. In our CMS each language blog is a separate site, and for fourteen years a translation was published as a new, unrelated article. Google's hreflang wants, for every article, the full list of its language versions, and it wants the list on every one of them. We had the articles. We did not have the list.&lt;/p&gt;

&lt;p&gt;One line of context so you know where I stand: I run engineering at &lt;a href="https://www.goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, a no-code app platform headquartered in Ajaccio, Corsica. I have no religion about where a model runs. This post is about one job where a two-year-old open-weight model on a laptop was the right tool, with the numbers to show it, and about why we are hosting a Hacktoberfest Fest on exactly this subject on October 21.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a judgment call, not a join
&lt;/h2&gt;

&lt;p&gt;Every obvious key fails. The number at the end of each URL (&lt;code&gt;-a1332&lt;/code&gt; in French, &lt;code&gt;-a1486&lt;/code&gt; in English for the same article) is a per-blog counter. Publication dates are days apart for a translation, sometimes months. Titles are translated freely, and the monthly "What's new at GoodBarber" post carries the same title every month in every language. Slug overlap works when the translator kept the English words and fails precisely when they did their job.&lt;/p&gt;

&lt;p&gt;A person would read the summary of the French article, read the summaries of the English candidates, and decide. That is a language model's job. The question was which one, and where.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recipe: four scripts, one afternoon of GPU time
&lt;/h2&gt;

&lt;p&gt;Hardware: a MacBook Pro with an M3 Max and 48 GB of memory. Runtime: &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;. Two models, both open weights: &lt;a href="https://ollama.com/library/gemma2" rel="noopener noreferrer"&gt;gemma2:27b&lt;/a&gt;, Google's June 2024 release, 15 GB on disk at the default 4-bit quantization, under the &lt;a href="https://ai.google.dev/gemma/terms" rel="noopener noreferrer"&gt;Gemma terms&lt;/a&gt;; and &lt;a href="https://ollama.com/library/bge-m3" rel="noopener noreferrer"&gt;bge-m3&lt;/a&gt;, BAAI's multilingual embedding model, MIT licensed, 1.2 GB, 1,024 dimensions. Around 600 lines of Python with httpx, no framework, no vector database.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Crawl, deterministically
&lt;/h3&gt;

&lt;p&gt;One script dumps every article of every language blog into a JSONL file, one line per article, from the CMS listing API. It flushes each page as it lands and skips ids already on disk, so it survives being interrupted.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;blog&lt;/th&gt;
&lt;th&gt;articles&lt;/th&gt;
&lt;th&gt;first post&lt;/th&gt;
&lt;th&gt;last post&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fr&lt;/td&gt;
&lt;td&gt;1,085&lt;/td&gt;
&lt;td&gt;2011-11-07&lt;/td&gt;
&lt;td&gt;2026-04-24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;en&lt;/td&gt;
&lt;td&gt;1,083&lt;/td&gt;
&lt;td&gt;2011-11-07&lt;/td&gt;
&lt;td&gt;2026-04-24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;es&lt;/td&gt;
&lt;td&gt;893&lt;/td&gt;
&lt;td&gt;2013-01-24&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;892&lt;/td&gt;
&lt;td&gt;2013-01-24&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pt&lt;/td&gt;
&lt;td&gt;778&lt;/td&gt;
&lt;td&gt;2013-01-24&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;de&lt;/td&gt;
&lt;td&gt;683&lt;/td&gt;
&lt;td&gt;2013-11-25&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nl&lt;/td&gt;
&lt;td&gt;478&lt;/td&gt;
&lt;td&gt;2013-11-25&lt;/td&gt;
&lt;td&gt;2026-04-23&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;5,892 articles, crawled on the morning of May 4, 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Narrow before you ask
&lt;/h3&gt;

&lt;p&gt;French is the pivot: it is the source language of most of our posts and the largest blog. For each French article and each of the six other languages, the candidates are the articles of that language published within 90 days of the French one, closest first, twenty at most. The model never sees the 1,083 English posts. It sees at most twenty summaries and picks one, or none.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DATE_WINDOW_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
&lt;span class="n"&gt;TOP_K_CANDIDATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="n"&gt;MIN_CONFIDENCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where most of the accuracy comes from, and it costs nothing. A retrieval step does not need to be clever; it needs to make the question small.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Ask one small question, in JSON, at temperature zero
&lt;/h3&gt;

&lt;p&gt;The prompt, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Match translations of blog articles between languages.

RULES:
- A match means the SAME article translated - same specific content, same arguments,
  same examples - not just the same broad topic or shared keywords.
- Two articles can share a keyword (e.g. 'no-code', 'GoodBarber', 'app') and still be
  DIFFERENT articles. Reject those.
- Compare the FULL summary, not just the title. Look for the same specific subject,
  same angle, same takeaways.
- If you are not sure it's the same exact article, set index=null.
- Return high confidence (&amp;gt;0.8) only when the summary clearly describes the same content.

SOURCE (fr):
title: ...
summary: ...
date: ...

CANDIDATES (en):
[0] title: ...
    summary: ...
    date: ...
[1] ...

Reply JSON only: {"index": &amp;lt;int&amp;gt;|null, "confidence": &amp;lt;0..1&amp;gt;}. Default to index=null when unsure.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/generate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemma2:27b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules around it. An answer under 0.7 confidence is discarded. An article can belong to one row only, so a matched URL leaves the candidate pool for everyone else. And after every pivot the CSV is rewritten atomically, with a sidecar file recording which (pivot, language) pairs were already attempted, so a Ctrl-C or a rerun never pays for a prompt twice.&lt;/p&gt;

&lt;p&gt;That sidecar exists because I reran the thing several times while changing the prompt and the thresholds. With a hosted API, each rerun is a line on a bill and a rate limit to negotiate. Here it was a keystroke.&lt;/p&gt;

&lt;p&gt;I measured the cost of one prompt again today, September 9, on the same laptop, with the same code and the same model: the French "GoodBarber vs Glide" article against its thirteen English candidates within the window, 1,342 prompt tokens, 16 tokens out. Cold, 24.7 seconds, of which 11.2 to load the model. Warm, 2.3 seconds. Both runs answered &lt;code&gt;{"index": 2, "confidence": 0.95}&lt;/code&gt;, and index 2 is the right article. About 6,500 (pivot, language) pairs at that speed is roughly four hours of GPU time. The crawl ran on May 4; the CSV was last written on May 6 at 20:49. Sent to a hosted model, those eleven million or so prompt tokens would have cost somewhere between a couple of dollars and a couple of hundred depending on the model. Money was never the argument. The meter was.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Verify with a different model
&lt;/h3&gt;

&lt;p&gt;The generator is the judge. It should not also be the reviewer. Three layers, cheapest first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic checks.&lt;/strong&gt; Host matches the column, slug has the expected shape, each URL appears once in its column, and the publication dates within a row span 90 days at most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slug overlap.&lt;/strong&gt; A cell whose slug shares almost no words with the French slug, in a row where the other cells share plenty, gets flagged. It is a heuristic with known false positives on well-translated slugs; it only surfaces candidates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second model.&lt;/strong&gt; bge-m3 embeds the title plus the first 300 characters of the summary for all 5,892 articles (an 82 MB cache on disk, once). For each row, pairwise cosine similarity between the cells. A cell whose mean similarity to its row-mates drops 0.15 below the others is an outlier; a row whose mean is under 0.55 is weak. In fix mode the script proposes, for each outlier, the article of that language closest to the centroid of the other cells, and only if it scores at least 0.70 against the centroid, at least 0.70 against the French pivot, and beats the current cell by at least 0.10.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two models disagreeing is the review queue. A human reads the queue, not 5,325 cells.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out
&lt;/h2&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;rows&lt;/th&gt;
&lt;th&gt;en&lt;/th&gt;
&lt;th&gt;es&lt;/th&gt;
&lt;th&gt;it&lt;/th&gt;
&lt;th&gt;pt&lt;/th&gt;
&lt;th&gt;de&lt;/th&gt;
&lt;th&gt;nl&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;French pivots&lt;/td&gt;
&lt;td&gt;1,085&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cells filled&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;959&lt;/td&gt;
&lt;td&gt;766&lt;/td&gt;
&lt;td&gt;760&lt;/td&gt;
&lt;td&gt;688&lt;/td&gt;
&lt;td&gt;616&lt;/td&gt;
&lt;td&gt;451&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;5,325 of the 5,892 articles landed in a row, 90.4 percent. 347 rows are complete in seven languages, 151 more in six.&lt;/p&gt;

&lt;p&gt;The gaps are older than the model. The Spanish, Italian and Portuguese blogs opened in January 2013 and the German and Dutch ones in November 2013, and the early years published a lot of local-only content that was never translated. So the 193 French posts of 2013 yielded 227 matched cells and no complete row, which is the right answer, not a miss. From 2019 on, more than half of the French posts have all six translations.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;year of the French post&lt;/th&gt;
&lt;th&gt;French posts&lt;/th&gt;
&lt;th&gt;matched cells&lt;/th&gt;
&lt;th&gt;complete rows (6 of 6)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2013&lt;/td&gt;
&lt;td&gt;193&lt;/td&gt;
&lt;td&gt;227&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2015&lt;/td&gt;
&lt;td&gt;142&lt;/td&gt;
&lt;td&gt;542&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2018&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;226&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2021&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;354&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;td&gt;473&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Two implementations, one list
&lt;/h2&gt;

&lt;p&gt;We had no ground truth. Nobody was going to grade 5,325 pairs by hand, and a validator written by the same person with the same assumptions grades itself. So over the same two weeks in May, a colleague, Marc, built a second implementation with nothing in common with mine: a different method, an embedding model and a nearest-neighbour index (pgvector) instead of a generator and a prompt, the same French pivot, a similarity threshold at 0.60, and a preference for the French article that shares the same thumbnail image, by hash, when one exists. No date window, no candidate list, no shared code, and no import of my CSV. Two lists, built blind. The rule was simple: when they said the same thing, we would stop.&lt;/p&gt;

&lt;p&gt;I pulled the export of the second implementation today and diffed it against my May file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its export lists 1,137 rows today; 1,051 of my 1,085 French pivots are in it.&lt;/li&gt;
&lt;li&gt;Of the 4,122 cells both pipelines filled, 4,037 are identical: 97.9 percent.&lt;/li&gt;
&lt;li&gt;85 differ. I read the first 40 by hand: in 38 the second implementation is right and mine is wrong, the other two are a toss-up. The errors cluster in three families: serial posts (the monthly "What's new", the engine revision announcements, where a 240-character summary does not carry the edition), topic pairs published in the same window (two reseller posts, two native-ads posts, which my 90-day filter served up together), and a handful where the summary simply was too short.&lt;/li&gt;
&lt;li&gt;284 cells the second implementation filled that mine had left empty, and 110 that mine filled and it leaves empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two implementations that share nothing but the corpus, landing on the same answer 98 times out of 100: that was the stop criterion, and we stopped. Since June our blog sitemaps carry the alternates, rebuilt from that export on every regeneration, best-effort: if the export is unavailable, the sitemap is generated without alternates rather than not at all. As of today, on our seven hosts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;host&lt;/th&gt;
&lt;th&gt;blog URLs in sitemaps&lt;/th&gt;
&lt;th&gt;with hreflang alternates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;www (en)&lt;/td&gt;
&lt;td&gt;1,107&lt;/td&gt;
&lt;td&gt;983&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fr&lt;/td&gt;
&lt;td&gt;1,084&lt;/td&gt;
&lt;td&gt;1,022&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;es&lt;/td&gt;
&lt;td&gt;945&lt;/td&gt;
&lt;td&gt;844&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;943&lt;/td&gt;
&lt;td&gt;854&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pt&lt;/td&gt;
&lt;td&gt;888&lt;/td&gt;
&lt;td&gt;801&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;de&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;701&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nl&lt;/td&gt;
&lt;td&gt;561&lt;/td&gt;
&lt;td&gt;536&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;5,741 of 6,296 URLs, 91.2 percent. The alternates live in the sitemaps only, not in the pages' &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, which Google accepts as one of the three supported ways to declare them. One entry, as served today, shortened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;url&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://www.goodbarber.com/blog/design-trends-2026-...-a1608/&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"fr"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fr.goodbarber.com/blog/tendances-design-2026-...-a1439/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://www.goodbarber.com/blog/design-trends-2026-...-a1608/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"es"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://es.goodbarber.com/blog/tendencias-de-diseno-2026-...-a1137/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"it"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://it.goodbarber.com/blog/tendenze-design-2026-...-a1102/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"pt"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://pt.goodbarber.com/blog/tendencias-de-design-2026-...-a1341/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"de"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://de.goodbarber.com/blog/designtrends-2026-...-a1441/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"nl"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://nl.goodbarber.com/blog/designtrends-2026-...-a1435/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same seven lines appear under the French, Spanish, Italian, Portuguese, German and Dutch URLs of that article in their own sitemaps, which is what makes the set reciprocal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you about open weights, after this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Task design beats model size.&lt;/strong&gt; The 90-day window and the twenty-candidate cap did more for accuracy than any model choice would have. A June 2024 model at 4-bit was enough because it was never asked to search; it was asked to compare twenty summaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeatability is a feature.&lt;/strong&gt; Temperature zero and JSON mode gave me the same answer on the same prompt today as in May. Debugging a matcher that answers differently on each run is not debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The meter changes what you build.&lt;/strong&gt; The attempts sidecar, the reruns, the fix loop: none of it would exist at a price per prompt. Zero marginal cost is not about saving money on the final run. It is about how many times you are willing to be wrong on the way there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify with a second, cheaper model.&lt;/strong&gt; bge-m3 is 1.2 GB and MIT licensed. It never decides; it reviews. Two models with different failure modes are worth more than one bigger model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build it twice.&lt;/strong&gt; When there is no ground truth and no budget to make one, two implementations that share nothing, not the model, not the method, not the code, not the author, are a test suite you can afford. Where they agree, you are done. Where they disagree, you have a review queue, and 85 cells is a queue a person can read. Gemma 4 was already on the same disk in May, by the way; I ran the two-year-old one because it felt faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy was not the argument here.&lt;/strong&gt; Blog posts are public. But the crawl hit an internal API on a 192.168 address and nothing left the LAN, so if your corpus is not public, the same recipe runs unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hacktoberfest 2026 comes to Ajaccio, and we are hosting
&lt;/h2&gt;

&lt;p&gt;Hacktoberfest changed shape this year. It is now run by Major League Hacking and DEV, in partnership with DigitalOcean, and it stopped counting pull requests. The 2026 edition is 300-plus in-person Fests plus a global online event, all about building with open source AI: write your first skills.md, build an open-source agent, fine-tune an open-weight model. The tagline is "AI belongs to everyone", and after the afternoon described above I have no argument with it.&lt;/p&gt;

&lt;p&gt;GoodBarber is hosting the Ajaccio Fest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When:&lt;/strong&gt; Wednesday, October 21, 2026, 18:00 to 21:00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where:&lt;/strong&gt; CampusPlex, 95 cours Napoléon, Ajaccio, in the middle of town.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Program:&lt;/strong&gt; doors at 18:00; at 18:30 a talk on building with open source and open-weight AI, why it matters and concrete ways to start; Q&amp;amp;A and open discussion at 19:15; drinks and time to talk to the people around you from 20:00; end at 21:00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language:&lt;/strong&gt; the talk is in French; slides and shared resources are in English so you can keep going with the wider Hacktoberfest community afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price:&lt;/strong&gt; free. Registration on the &lt;a href="https://events.mlh.com/events/14893-hacktoberfest-meetup-ajaccio-x-goodbarber" rel="noopener noreferrer"&gt;MLH event page&lt;/a&gt; is recommended. Open to working professionals and university students.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No conference format, no pitch. We are one of the few tech companies headquartered on the island, and most developers here work alone; the point of the evening is to get them in one room on a weekday. If you have contributed to open source for years, come. If you have only been curious about where to start, come with a laptop, install Ollama and pull one model beforehand, and the recipe above fits in an evening.&lt;/p&gt;

&lt;p&gt;Fourteen years of posts got their hreflang from a 15 GB file and an afternoon. The interesting part was never the model. It was that nothing stood between me and trying.&lt;/p&gt;

</description>
      <category>hacktoberfest</category>
      <category>ai</category>
      <category>opensource</category>
      <category>seo</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>Trust is the net, not the eyes: can you ship AI-built software you can't review?</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Tue, 08 Sep 2026 07:26:13 +0000</pubDate>
      <link>https://dev.to/goodbarber/trust-is-the-net-not-the-eyes-can-you-ship-ai-built-software-you-cant-review-1gfm</link>
      <guid>https://dev.to/goodbarber/trust-is-the-net-not-the-eyes-can-you-ship-ai-built-software-you-cant-review-1gfm</guid>
      <description>&lt;p&gt;Can you trust software built with AI by someone who can't read the code it produced? The whole debate keeps circling one question: did a human check it? I build these systems for a living, and the question I'd rather answer is a harder one: would anything have caught the mistake before it shipped?&lt;/p&gt;

&lt;h2&gt;
  
  
  The fear is a proxy
&lt;/h2&gt;

&lt;p&gt;This started as an exchange on X. I had written that "did a human check it?" is the wrong question about AI-built software, and someone sent back a better one:&lt;br&gt;
"AI didn't replace builders — it exposed the real gap: trust. Builders don't just assemble apps; they earn the trust to ship them. What do you think breaks that trust faster: a flaw in the AI's code, or the fear that no human ever looked?"&lt;br&gt;
The fear, clearly. But I think the fear stands in for something else. Nobody actually wants a human to have read every line; people want to know that something would have caught the mistake. Almost everything written about AI and code quality answers the first demand and leaves the second one alone. Trust is the net, not the eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  We trust people. Planes tell the whole story.
&lt;/h2&gt;

&lt;p&gt;Trust is anthropomorphic, it attaches to faces. Aviation shows how deep that runs: in &lt;a href="https://www.cnbc.com/2017/08/07/who-would-be-willing-to-fly-in-a-pilotless-plane-hardly-anyone.html" rel="noopener noreferrer"&gt;a UBS survey&lt;/a&gt;, only 17% of passengers said they would board a pilotless plane, and more than half wouldn't buy the ticket even if it were cheaper. &lt;a href="https://www.ipsos.com/en-us/news-polls/Few-Would-be-Comfortable-with-Flying-on-Pilotless-Airliners" rel="noopener noreferrer"&gt;An Ipsos poll&lt;/a&gt; found 81% of Americans uncomfortable with the idea. People want a human in the cockpit, and no discount changes their mind.&lt;/p&gt;

&lt;p&gt;Now look at what actually made flying the safest way to travel, because it wasn't the pilot's eyesight. Checklists, written after crashes, so that no landing depends on someone remembering. Two pilots, because any one person will eventually be tired, sick or wrong. Redundant instruments, mandatory maintenance intervals. Aviation never found better humans; it wrapped ordinary, fallible ones in a structure that catches what they miss. The passenger's trust goes to the pilot, and the catching is done by everything around him.&lt;br&gt;
I think software is at the moment where it has to learn the same distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meanwhile, the eyes are losing ground
&lt;/h2&gt;

&lt;p&gt;Even where competent eyes exist, the guarantee is dissolving. I've reviewed enough code to vouch for the first half: we miss things. Every incident postmortem I've read includes a change that a competent person approved.&lt;/p&gt;

&lt;p&gt;The volume takes care of the second half. &lt;a href="https://www.faros.ai/blog/ai-acceleration-whiplash-takeaways" rel="noopener noreferrer"&gt;Faros AI's engineering report&lt;/a&gt;, built on telemetry from twenty-two thousand developers, measured what AI adoption does to the review pipeline: pull requests merged without any review at all are up 31.3%, and median time in review has quadrupled. Reviewers haven't gotten lazier, the queue simply outruns them. So "a human looked at it" is failing on both ends: it was never sufficient, and it's becoming rare.&lt;/p&gt;

&lt;p&gt;None of this makes review useless. It just can't be the thing the whole weight of trust rests on, and an automated reviewer doesn't change that: still a pair of eyes bolted on after the fact, just faster ones. The net is whatever was already there before anything shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The less you know, the more net you need
&lt;/h2&gt;

&lt;p&gt;Here's the part the debate skips, and the reason it concerns me directly. All the standard advice about AI-generated code assumes a particular reader: a developer who inspects the output, someone who knows what to look for. For that person, AI is an accelerator with a known failure mode, and their own judgment is the control.&lt;/p&gt;

&lt;p&gt;Take that person away. The maker who describes an app in plain language and gets working software back, the exact promise of no-code and vibe coding alike, cannot be the check. It isn't carelessness: checking requires knowing what failure looks like, and that knowledge is precisely what they didn't need in order to build. Telling them "review the output carefully" is telling them to inspect a bridge with no idea what a crack is.&lt;/p&gt;

&lt;p&gt;That inverts the usual hierarchy: the people with the least domain knowledge are the ones who need the most protection, and they need it built into the floor they stand on, because there is no crew around them. If trust has to come from somewhere, it can only come from the system underneath, &lt;a href="https://dev.to/goodbarber/ai-didnt-kill-the-framework-it-made-it-more-valuable-3n5p"&gt;the layer whose job is to carry the judgment the person on top can't supply&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engine is a net
&lt;/h2&gt;

&lt;p&gt;For me this part was never abstract, because building the net is most of the job. I run frontend engineering at GoodBarber, an app builder, and the engine we build is, among other things, an accumulation of catches. I gave a talk about this at the No Code Summit, &lt;a href="https://www.goodbarber.com/blog/advanced-customization-of-goodbarber-apps-a1300/" rel="noopener noreferrer"&gt;transcribed on our blog&lt;/a&gt;: the talk was about safeguards, stopping the obvious mistake from shipping without standardizing what people build. Set an ad frequency aggressive enough to degrade the reading experience, and a floor holds. Push a configuration past what the layout can carry, and the engine transforms it instead of rendering the mistake, a mechanism I've detailed in &lt;a href="https://dev.to/goodbarber/the-rendering-engine-how-a-no-code-project-becomes-a-smooth-native-app-nd4"&gt;the article on our rendering engine&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;None of these catches is spectacular, which is sort of the point. A net is an accumulation of places where a mistake runs out of room, each one settled once, in the structure, on behalf of people who will never know the question existed. Checklists, second instruments, walls an error can't cross: aviation wrote that shape down a century ago, and engines like ours have been writing the software version of it for fifteen years, since long before anyone called this an AI problem.&lt;/p&gt;

&lt;p&gt;And it runs deeper than what you see on screen: my colleague Pierre-Laurent has written about &lt;a href="https://dev.to/goodbarber/privacy-by-design-at-the-binary-level-no-ghost-sdk-in-your-build-1bl0"&gt;privacy guarantees enforced at the binary level&lt;/a&gt;, a catch nobody has to remember exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI on top, humans underneath
&lt;/h2&gt;

&lt;p&gt;Put AI on top of that picture, because that's where it's landing. Generation raises the need for the net: more people building without the knowledge to check, and mistakes produced faster than anyone can read them. An AI generating into the void ships whatever comes out; the same AI generating into an engine meets the floors, the transformations and the walls that were already there for humans.&lt;/p&gt;

&lt;p&gt;One principle from our own work with this, because it's the most reusable thing I know on the subject: the line between "the AI does it" and "the AI proposes it" shouldn't be drawn by how confident the model sounds, but by whether the mistake can be walked back. What's reversible can run. What isn't, waits for a human. &lt;br&gt;
That line ended up deciding more than what the AI may touch: it set the pace at which my team stopped reviewing every pull request by hand, &lt;a href="https://dev.to/goodbarber/our-tools-are-built-for-humans-not-for-agents-5f34"&gt;a story I've told separately&lt;/a&gt;. (Deciding what the AI gets to do inside our own tool was a story in itself. &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;My colleague Dominique has told it&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;That, I think, is the durable place of these engines in the AI era: carrying the part of trust that neither the model nor the maker can carry alone. Built by humans, with AI generating on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust is a property of the system
&lt;/h2&gt;

&lt;p&gt;So, back to the fear. Promising that a human looked won't cure it; that promise is already breaking inside professional teams, and it was never available to the maker building alone. What cures it is catches you can name.&lt;/p&gt;

&lt;p&gt;Deciding which mistakes deserve a catch, and which are cheap enough to let through, is judgment work, the kind that &lt;a href="https://dev.to/goodbarber/when-generation-is-free-taste-is-everything-3k30"&gt;doesn't get automated away&lt;/a&gt; when generation gets cheap. And every check nobody formalizes doesn't disappear. It joins the bill someone pays later, usually at the worst time.&lt;/p&gt;

&lt;p&gt;Some will read all this as giving up on humans. I see it as the opposite: taking what careful people do and making it survive their absence. The rule I come back to is the one I'd offer anyone building with AI today: you don't fully hand off what you can't undo.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mathieu Poli — Head of Frontend Engineering @ GoodBarber. &lt;br&gt;
I teach and write about frontend engineering, product design, and AI — and everything that happens when the three meet.&lt;br&gt;
X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;@hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Photo: &lt;a href="https://unsplash.com/photos/silhouette-of-a-trapeze-artist-swinging-high-above-lTv2oYFaAmE" rel="noopener noreferrer"&gt;Maya Alexa G. Romero&lt;/a&gt; / Unsplash&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>webdev</category>
      <category>nocode</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>
  </channel>
</rss>
