<?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: Jackson Ly</title>
    <description>The latest articles on DEV Community by Jackson Ly (@jacksonxly).</description>
    <link>https://dev.to/jacksonxly</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4007401%2F57bfa95e-a3e3-42db-8089-c13d9d6d3a54.jpg</url>
      <title>DEV Community: Jackson Ly</title>
      <link>https://dev.to/jacksonxly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jacksonxly"/>
    <language>en</language>
    <item>
      <title>Refreshing a token I did not own logged our account out</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Tue, 11 Aug 2026 17:34:25 +0000</pubDate>
      <link>https://dev.to/jacksonxly/refreshing-a-token-i-did-not-own-logged-our-account-out-3nah</link>
      <guid>https://dev.to/jacksonxly/refreshing-a-token-i-did-not-own-logged-our-account-out-3nah</guid>
      <description>&lt;p&gt;We run a monitoring job that checks a Bluesky account for replies. Last week I fixed a real bug in it, and the fix took the account offline for two days. The bug was worth fixing. The fix was the wrong shape, for a reason that is easy to miss when you are integrating with an app you did not write.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I was fixing
&lt;/h2&gt;

&lt;p&gt;The job read the session out of the browser's local storage and called the notification endpoint with it. Once the token expired, every call came back as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ExpiredToken"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Token has expired"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job never checked. It read &lt;code&gt;notifications&lt;/code&gt; off the response, got &lt;code&gt;undefined&lt;/code&gt;, counted zero, and reported no new inbound.&lt;/p&gt;

&lt;p&gt;That is the failure mode worth caring about, because it does not look like a failure. An empty result from a quiet account and an empty result from a dead credential are byte-identical in the log. I only caught it because the number was too round: every prior run had read 26 notifications with a stable 14 reply / 9 like / 3 follow split, and then the counts came back as an empty object. The zero was the anomaly.&lt;/p&gt;

&lt;p&gt;So the check now asserts that the call succeeded instead of trusting the shape of the result:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&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="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="s2"&gt;`sweep failed: &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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notifications&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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="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="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notifications&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="s1"&gt;sweep returned no array&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That part was right and I would write it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I went wrong
&lt;/h2&gt;

&lt;p&gt;Having found an expired token, I made the job refresh it. One call to &lt;code&gt;com.atproto.server.refreshSession&lt;/code&gt;, take the new access token, retry the sweep. It worked on the first run.&lt;/p&gt;

&lt;p&gt;Two runs later the account was signed out. Not expired, signed out, sitting on the create-account screen, with &lt;code&gt;refreshSession&lt;/code&gt; now returning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ExpiredToken"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Token has been revoked"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revoked is a different word from expired, and I had caused it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one successful refresh breaks the next one
&lt;/h2&gt;

&lt;p&gt;AT Protocol refresh tokens are single-use and rotating. Presenting one gets you a new pair and destroys the one you presented. The SDK docs put it plainly: &lt;a href="https://atproto.blue/en/latest/atproto_client/auth.html" rel="noopener noreferrer"&gt;"On the token refresh, the old refresh token will be revoked instantly"&lt;/a&gt;, and "You must use only the fresh pair of tokens (access + refresh). It's not possible to reuse the same refresh token multiple times." Access tokens last about two hours, refresh tokens about two months.&lt;/p&gt;

&lt;p&gt;It is worth being precise about where that is documented, because I got it wrong in my own notes first. The &lt;a href="https://atproto.com/specs/xrpc" rel="noopener noreferrer"&gt;XRPC spec&lt;/a&gt; describes the refresh endpoint and the access/refresh split, but it never says the tokens are single-use. That behaviour is in the SDK documentation, and in the &lt;a href="https://github.com/bluesky-social/atproto/discussions/2724" rel="noopener noreferrer"&gt;maintainer discussion&lt;/a&gt; the position is that clients should expect rotation and expiry at any time, since the policy is left to the auth server. Reasoning from the protocol spec alone will not get you there.&lt;/p&gt;

&lt;p&gt;Now add the part about ownership. The browser app was holding that session and refreshing it on its own schedule. My job reached into its storage, spent its refresh token, and got back a replacement pair that I deliberately did not write back, because the app owns the shape of that storage object and writing into it is its own kind of bug.&lt;/p&gt;

&lt;p&gt;The app was left holding a token that had already been consumed. It could still read for a couple of hours on the unexpired access token, which is why the first run looked fine. The moment it tried to refresh, its token was gone and the session dropped.&lt;/p&gt;

&lt;p&gt;The breakage is delayed by exactly one access-token lifetime, which is what makes it hard to catch. The harmful call and the visible symptom are hours apart, so by the time anything looks wrong you have stopped suspecting the thing you changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Never spend a single-use credential that another program owns. Read its tokens if you have to, but do not rotate them.&lt;/p&gt;

&lt;p&gt;Rotation is a write, even though it looks like a read. Nothing in the call signature suggests you are mutating shared state, and the request that consumes the token looks identical to one that just fetches. If two programs share a session and both can refresh, the one that refreshes without persisting the result quietly destroys the other's ability to continue. None of this is specific to atproto. It is how rotating refresh tokens work wherever they are implemented, which is why the SDK ships an &lt;code&gt;on_session_change&lt;/code&gt; hook whose entire job is to make you persist the rotated pair.&lt;/p&gt;

&lt;p&gt;Once you accept that, the recovery is to make the owner do the refresh. Reload the app, give it a few seconds to refresh on its own schedule, read the fresh token back out, retry once. If that still fails, it is a genuine block and a human needs to sign in. That is cheaper than the alternative and it cannot corrupt anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I did not expect
&lt;/h2&gt;

&lt;p&gt;While the account was signed out I went looking for how much of the job could still run, assuming the answer was none of it.&lt;/p&gt;

&lt;p&gt;Most of it, as it turned out. The reads I actually needed were public:&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="err"&gt;GET /xrpc/com.atproto.repo.listRecords?repo=&amp;lt;did&amp;gt;&amp;amp;collection=app.bsky.feed.post
GET /xrpc/app.bsky.feed.getPostThread?uri=&amp;lt;at-uri&amp;gt;
GET /xrpc/app.bsky.feed.getPosts?uris=&amp;lt;at-uri&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No token on any of them. That was enough to pull 77 of our own posts, walk the threads under the six that had replies, and work out which replies were still unanswered by diffing against the parent URIs in our own outbox. It gave the same answer the authenticated notification sweep would have, from endpoints that cannot log anybody out. The engagement counts I use for measurement came back the same way.&lt;/p&gt;

&lt;p&gt;Only the notification list itself needs auth, and notifications were never the thing I wanted. They were the thing I reached for because they were the obvious surface, and reaching for them is what put a credential in the loop to begin with.&lt;/p&gt;

&lt;p&gt;So before you build refresh handling, check whether the data is public. An unauthenticated read has no session to lose, and for anything you published yourself there is a decent chance it is already sitting behind a public endpoint.&lt;/p&gt;

</description>
      <category>api</category>
      <category>oauth</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A 200 response is not a page, and your policy check is grepping an empty shell</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Sun, 09 Aug 2026 11:27:31 +0000</pubDate>
      <link>https://dev.to/jacksonxly/a-200-response-is-not-a-page-and-your-policy-check-is-grepping-an-empty-shell-3ah1</link>
      <guid>https://dev.to/jacksonxly/a-200-response-is-not-a-page-and-your-policy-check-is-grepping-an-empty-shell-3ah1</guid>
      <description>&lt;p&gt;I run automated checks over terms of service and publisher agreements before we use a platform. Fetch the page, strip the tags, grep for the clauses that matter. It is not sophisticated and it has worked for months.&lt;/p&gt;

&lt;p&gt;This week it lied to me three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Same script, same extraction, three legal pages:&lt;/p&gt;

&lt;p&gt;peerlist.io/terms returned 60 characters of text. daily.dev/terms returned 70. substack.com/pa, which is a full publisher agreement, returned 1,345.&lt;/p&gt;

&lt;p&gt;For comparison, the same script on a server-rendered legal page returns between 20,000 and 35,000 characters. Substack's terms of use, fetched the same way, gives 22,045.&lt;/p&gt;

&lt;p&gt;All three responses were HTTP 200. Nothing errored. Nothing retried. The script reported no matching clauses and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than a 404
&lt;/h2&gt;

&lt;p&gt;Those three pages are client rendered. The HTML that arrives is a shell, and the text I care about gets assembled by JavaScript after load. Old news, and normally a visible problem, because you print the body, you see nothing, and you reach for a browser.&lt;/p&gt;

&lt;p&gt;What let it past me was the shape of the check rather than the shape of the page.&lt;/p&gt;

&lt;p&gt;My check is a negative assertion. The pass condition is "this grep found nothing." An empty document satisfies every negative grep ever written, so a page that failed to load produces the same verdict as a page that loaded fine and genuinely has no such clause. The failure is silent, and it resolves toward clean, which is the expensive direction.&lt;/p&gt;

&lt;p&gt;The Substack case is the one that would have cost me. That publisher agreement does contain a clause I needed to know about. My script pulled 1,345 characters of shell, found no match, and would have told me the document was clean if I had trusted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Any check whose success condition is absence has to prove the input was present first.&lt;/p&gt;

&lt;p&gt;That reads as obvious written down. It is not obvious while you are writing the thing, because the presence check is the part nobody writes. You write the grep, the grep works on the first page you try, and the invariant that the page actually loaded stays implicit forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;Three assertions before the grep runs, and a verdict of unresolved rather than clean when any of them fail.&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;MIN_LEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;          &lt;span class="c1"&gt;# a real legal page is 20k+, a shell is under 1.5k
&lt;/span&gt;&lt;span class="n"&gt;SENTINELS&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;terms&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;agreement&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;policy&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;last updated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patterns&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MIN_LEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNRESOLVED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;only &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; chars, probably a client-rendered shell&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SENTINELS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNRESOLVED&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;no legal-document markers, wrong page or a redirect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;patterns&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HITS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hits&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;hits&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLEAN&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The length floor does most of the work. Pick it from your own corpus rather than from a guess: fetch ten pages you know are fine, take the smallest, halve it.&lt;/p&gt;

&lt;p&gt;The sentinel check catches the second thing that bit me. You can pull 30,000 characters of perfectly good text off entirely the wrong document, because the URL redirected to a marketing page, or because a 404 rendered as a styled landing page instead of a status code.&lt;/p&gt;

&lt;p&gt;Unresolved is a real state and it needs somewhere to go. Mine escalates to a headless browser, which costs about a second and settles it. The point is that unresolved never quietly collapses into clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second trap: the URL you were given may not be the URL
&lt;/h2&gt;

&lt;p&gt;substack.com/publisher-agreement returns 404. The document exists and lives at substack.com/pa. I only found it because the footer link on the terms page pointed there.&lt;/p&gt;

&lt;p&gt;So stop guessing slugs. Fetch a page you know exists, read its links, and follow the one whose anchor text matches what you want:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index_url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;urljoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;href&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;   &lt;span class="c1"&gt;# not found is not the same as not there, so escalate
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines of real work, and it survives the vendor reorganising their legal pages, which they do more often than you would expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else this shape turns up
&lt;/h2&gt;

&lt;p&gt;Status page checks that pass because the incident list never rendered. Sitemap validators reporting zero broken links because the sitemap came back empty. Index freshness checks reporting no stale documents because the query errored and returned an empty set. Anything that counts problems and alerts on a nonzero count will report perfect health the moment its input pipeline dies.&lt;/p&gt;

&lt;p&gt;The tell is the same every time: the healthy state and the broken state produce identical output. If your monitoring cannot separate "nothing wrong" from "nothing checked", it is not monitoring that thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Feed your check an empty string once and read what it says. If it says everything is fine, you have found the bug.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written from a week of policy checks that returned 200 and told me nothing. Drafted with AI assistance, verified against the live pages described, and edited by hand.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>automation</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your agent's inbox check is measuring read state, not answered state</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Sat, 08 Aug 2026 21:28:02 +0000</pubDate>
      <link>https://dev.to/jacksonxly/your-agents-inbox-check-is-measuring-read-state-not-answered-state-fkk</link>
      <guid>https://dev.to/jacksonxly/your-agents-inbox-check-is-measuring-read-state-not-answered-state-fkk</guid>
      <description>&lt;p&gt;If you run an agent that answers inbound, it needs exactly one boolean per item: have we replied to this yet. Every platform hands you something that looks like that boolean, and none of them actually is.&lt;/p&gt;

&lt;p&gt;I spent this week finding three different ways that gap swallows real messages. All three came out of the same agent, on three different platforms, and the fix turned out to be the same shape every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Read state gets cleared by looking at it
&lt;/h2&gt;

&lt;p&gt;On Reddit, &lt;code&gt;GET /message/unread.json&lt;/code&gt; returns your unread items. Fetching it does not mark them read, which sounds convenient right up until you notice the other half: answering does not mark them read either.&lt;/p&gt;

&lt;p&gt;So the counter only ever drifts. You answer someone, the item stays unread, and &lt;code&gt;inbox_count&lt;/code&gt; keeps reporting work that no longer exists.&lt;/p&gt;

&lt;p&gt;That is harmless if the counter is decoration. Ours was not. The scheduler treats &lt;code&gt;inbox_count &amp;gt; 0&lt;/code&gt; as a reason to skip a cheap no-op check and run the full sweep instead, so a single answered-but-unread item made every subsequent run do the expensive thing for nothing.&lt;/p&gt;

&lt;p&gt;The fix is to close the loop yourself, after you have actually handled the item:&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="err"&gt;POST https://old.reddit.com/api/read_message
body:    id=t1_&amp;lt;comment_id&amp;gt;&amp;amp;uh=&amp;lt;modhash&amp;gt;
header:  X-Modhash: &amp;lt;modhash&amp;gt;      # from /api/me.json -&amp;gt; data.modhash
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering matters more than the call. Mark read &lt;em&gt;after&lt;/em&gt; you have confirmed the thing is dealt with, because marking read is precisely what hides it from the next run.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The badge counts things you will never act on
&lt;/h2&gt;

&lt;p&gt;Bluesky gives you an unread badge. It counts likes, follows, reposts and replies the same way.&lt;/p&gt;

&lt;p&gt;One morning the badge showed a single unread. That unread was a like. Sitting underneath it, already flagged read and therefore invisible to anything that triages on the badge, was a four round technical thread whose last message had been mine to answer for five days.&lt;/p&gt;

&lt;p&gt;Nothing was broken. The badge answered the question it was designed to answer, which is "is there anything new", and I was asking it "is there anything owed".&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notifs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listNotifications&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;conversational&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;notifs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reply&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mention&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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;Filter by reason, and ignore the read flag entirely. A reply you have never answered goes read the moment anything fetches the list, which is to say the moment you look without acting.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The thread view disagrees with itself
&lt;/h2&gt;

&lt;p&gt;This is the one that would have made me reply to the same person twice.&lt;/p&gt;

&lt;p&gt;Having fixed the badge problem, I wrote what looks like the obvious check: fetch the thread for their message and see whether any reply on it is mine.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPostThread&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theirPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;replies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;me&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 thread I had answered two hours earlier, that returned false. Their post reported &lt;code&gt;replyCount: 1&lt;/code&gt;. The &lt;code&gt;replies&lt;/code&gt; array came back empty. My reply existed, with &lt;code&gt;record.reply.parent.uri&lt;/code&gt; pointing at exactly that post.&lt;/p&gt;

&lt;p&gt;I do not know why the appview rendered it that way, and for this purpose it does not matter. What matters is that the check had a failure mode I had not designed for, and the failure pointed the wrong way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read your own outbox instead
&lt;/h2&gt;

&lt;p&gt;The fix that generalizes: stop asking whether they were answered, and start asking whether you answered.&lt;/p&gt;

&lt;p&gt;Your own sent items are authoritative. They do not depend on their read state, their badge semantics, or how their renderer decided to assemble a thread today.&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="c1"&gt;// Every parent we have ever replied to, from our own repo.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repliedTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listRecords&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;myDid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app.bsky.feed.post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;uri&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="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;repliedTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;conversational&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;repliedTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same shape works anywhere. On Reddit it is your own comments and their &lt;code&gt;parent_id&lt;/code&gt;. On a mail API it is your sent folder and the &lt;code&gt;In-Reply-To&lt;/code&gt; header. The lookup is cheap, and it is derived from something you control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The asymmetry that should drive the design
&lt;/h2&gt;

&lt;p&gt;These two failures are not equally bad, and that should decide how you build the check.&lt;/p&gt;

&lt;p&gt;A false "already answered" means you miss a reply. That is embarrassing and recoverable, and a human would forgive it.&lt;/p&gt;

&lt;p&gt;A false "still open" means you reply to the same person twice. On an account that posts automatically, that is what spam looks like from the outside, and it is not recoverable, because the second message is already sent.&lt;/p&gt;

&lt;p&gt;So when the two signals disagree, believe the one that says you already answered.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written up from a week of debugging an agent that answers its own inbound across four platforms. Drafted with AI assistance, verified against the live APIs described, and edited by hand.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Durable approval is not the same as valid approval</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Fri, 07 Aug 2026 19:31:52 +0000</pubDate>
      <link>https://dev.to/jacksonxly/durable-approval-is-not-the-same-as-valid-approval-3p1h</link>
      <guid>https://dev.to/jacksonxly/durable-approval-is-not-the-same-as-valid-approval-3p1h</guid>
      <description>&lt;p&gt;A few weeks ago we shipped a general command tool for a local agent, with a deny-list in front of it and a human approval gate behind it. I wrote at the time that the deny-list is the part that looks like engineering and does not hold, because you cannot enumerate the dangerous set. The gate is the part that holds.&lt;/p&gt;

&lt;p&gt;Someone pushed back with a good refinement. The gate only holds if approval is real state with a lifecycle: requested, then approved or rejected, then consumed, then expired. A confirmation step living inside the agent loop cannot survive the process dying while it waits, so it is not a control, it is a prompt.&lt;/p&gt;

&lt;p&gt;He is right, and most implementations skip it. What I want to add is that making approval durable does not finish the job. It moves the failure somewhere quieter.&lt;/p&gt;

&lt;h2&gt;
  
  
  An approval is granted against a world, not a string
&lt;/h2&gt;

&lt;p&gt;When someone clicks approve, they are approving the specific thing they were shown: this file, with this content, going to these two people. What you persist is usually a record that a decision happened, plus an identifier. That is not a record of what was decided.&lt;/p&gt;

&lt;p&gt;Between approve and consume, the world keeps moving. The file gets renamed, or a symlink under it now points somewhere else, or the recipient list picked up one more address while the process was down. Your token survives all of it, because it was never bound to any of it. The agent resumes after the crash exactly as designed and executes something the human never saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old name for this
&lt;/h2&gt;

&lt;p&gt;Time-of-check to time-of-use. You verify a condition, then act on it, and something changes in the gap. The textbook examples are filesystem races measured in microseconds.&lt;/p&gt;

&lt;p&gt;Human approval stretches that gap to minutes or hours, because a person gets the prompt, goes to lunch, comes back and clicks yes. Durability stretches it further on purpose, since surviving a restart is the entire feature. We took a race condition and made it comfortable enough to design around.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix nobody enjoys building
&lt;/h2&gt;

&lt;p&gt;Bind the approval to a fingerprint of what was actually rendered to the human. Not the intent, the literal resolved parameters. Recompute it at consume time, compare, and fail closed on any drift. Then expire aggressively, since expiry is the only real bound on how stale a decision can get.&lt;/p&gt;

&lt;p&gt;Fail closed is the part I keep seeing people soften, and I understand why. The drift is almost always innocent, a timestamp or a reordered field, and failing on it feels pedantic. It is pedantic. It is also the one behaviour that makes the gate mean anything on the day the drift is not innocent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than losing the approval
&lt;/h2&gt;

&lt;p&gt;If approval evaporates when the process dies, you get a bad experience and a safe failure. The agent comes back, finds nothing, and asks again. The human sees the current state and can say no.&lt;/p&gt;

&lt;p&gt;If approval survives unbound, you get a good experience and a silent failure. The agent finds a valid-looking token and proceeds against a world that moved. Nobody is asked anything. Nothing looks wrong in the logs, because from the system's side everything worked exactly as specified.&lt;/p&gt;

&lt;p&gt;So durability on its own is not a safety property. It is a convenience that only becomes a safety property once the persisted thing is specific enough to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Bind before you persist. Ship the approval carrying a hash of its resolved parameters from day one, even if the storage is a flat file and the lifecycle has two states. I would rather have a crude gate that revalidates than a carefully modeled lifecycle that trusts its own token, and I say that having built the second one first.&lt;/p&gt;

&lt;p&gt;The lifecycle framing is right. It is just the easy half.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Running local and cloud models in the same coding agent: what actually ships in 2026</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Fri, 07 Aug 2026 03:09:57 +0000</pubDate>
      <link>https://dev.to/jacksonxly/running-local-and-cloud-models-in-the-same-coding-agent-what-actually-ships-in-2026-18eo</link>
      <guid>https://dev.to/jacksonxly/running-local-and-cloud-models-in-the-same-coding-agent-what-actually-ships-in-2026-18eo</guid>
      <description>&lt;p&gt;Someone on r/LocalLLaMA asked a question this week that turned out to be harder to answer than it looks: which open-source coding harness lets a local model and a cloud model work on the same task, together, in one loop?&lt;/p&gt;

&lt;p&gt;The answers he got were a proxy and a shrug. He rejected both, correctly. So here is the actual answer, with the config knobs, and an honest account of the part that does not exist yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a gateway is not the answer
&lt;/h2&gt;

&lt;p&gt;The first suggestion for this is always LiteLLM, or Bifrost, or whatever OpenAI-compatible proxy is current. Those are good at what they do. They are not what the question is asking for.&lt;/p&gt;

&lt;p&gt;A proxy routes on what it can see in the request: the model name you asked for, weights you configured, health checks, budgets. It sits below the agent and has no idea whether the call it is forwarding is a throwaway commit message or the one architectural decision in the run. Point an agent at a gateway and you get failover and cost control. You do not get a local model and a cloud model dividing labour on a task, because nothing in the request says what the task needed.&lt;/p&gt;

&lt;p&gt;The signal you want lives one layer up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually ships, by declared role
&lt;/h2&gt;

&lt;p&gt;Three tools do this today. None of them frame it as routing, which is why searching for "routing" misses them. They all frame it as roles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cline&lt;/strong&gt; lets you set one model for Plan mode and a different one for Act mode. In settings, enable "Use different models for Plan and Act", then pick a model for each. Switching modes switches the model automatically, so a single task can be planned by one model and executed by another without you touching anything mid-run. The documented pairing is a stronger reasoning model for planning and a faster one for implementation, and nothing stops you pointing one of those at a local endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aider&lt;/strong&gt; takes three models in one session. &lt;code&gt;--model&lt;/code&gt; is the main one that does the editing. &lt;code&gt;--editor-model&lt;/code&gt; handles the edit-application step. &lt;code&gt;--weak-model&lt;/code&gt; picks up the cheap work like commit messages and history summarization. You set them independently, so a large cloud model can do the reasoning while a small local model absorbs the chatter that would otherwise burn tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continue&lt;/strong&gt; assigns models per role, and this is the closest thing to the original question. Autocomplete is a separate role from chat. Their docs cover running the autocomplete model locally through Ollama, and chat can sit on a cloud provider at the same time. That is genuinely a local model and a cloud model both live in one workflow, serving different jobs, all day.&lt;/p&gt;

&lt;p&gt;So the honest answer to "does anything blend local and cloud in one loop" is yes, three things do, and they have shipped it for a while. It just is not called routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, and the part nobody has
&lt;/h2&gt;

&lt;p&gt;Look at what those three have in common. In every case, &lt;strong&gt;you&lt;/strong&gt; declare the split once, and the harness honours it for the rest of the run. Plan gets this model. Autocomplete gets that one. Commit messages get the cheap one.&lt;/p&gt;

&lt;p&gt;What none of them do is decide the split themselves. No shipping harness looks at a subtask, works out that this one needs vision or a 400k context window or tool calling that survives twenty turns, and picks accordingly.&lt;/p&gt;

&lt;p&gt;That gap is not an oversight, and it is worth understanding why, because it tells you what to build if you are going to build it. The requirement is known inside the planner, at the moment a subtask is created. By the time a model is selected, that context is gone: the selection layer sees a prompt and a model name. Nobody has plumbed the requirement from where it is known to where it is needed. Until someone does, autonomous model selection has nothing to select on.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are wiring this yourself
&lt;/h2&gt;

&lt;p&gt;Two rules that survive contact with real work.&lt;/p&gt;

&lt;p&gt;Route on declared capability, never on estimated difficulty. Capability is checkable: this subtask has an image in it, this one exceeds the small model's context, this one needs a tool call. Those facts stay true tomorrow. Difficulty is a guess, it is not measurable at the point you need it, and every heuristic you write for it goes stale on the next model release. A rule that rots silently is worse than no rule.&lt;/p&gt;

&lt;p&gt;Put the cheap model where the work is bounded and verifiable. Commit messages, summarization, autocomplete, structured extraction with a schema you validate. In each case, a bad output is obvious immediately and costs one retry. That is why the shipping tools all made the same choice: the roles they hand to the weak model are exactly the roles where being wrong is cheap and detectable.&lt;/p&gt;

&lt;p&gt;The interesting version of this problem is not building a smarter router. It is getting the planner to say what each piece of work actually needs, in a form something downstream can act on. Everything else follows from that.&lt;/p&gt;

&lt;p&gt;Sources: Cline's Plan and Act docs, Aider's advanced model settings, and Continue's autocomplete deep dive, all checked while writing this.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Your interval-triggered agent pays full price for empty runs</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Wed, 05 Aug 2026 17:31:39 +0000</pubDate>
      <link>https://dev.to/jacksonxly/your-interval-triggered-agent-pays-full-price-for-empty-runs-adc</link>
      <guid>https://dev.to/jacksonxly/your-interval-triggered-agent-pays-full-price-for-empty-runs-adc</guid>
      <description>&lt;p&gt;We run an agent on an interval. It wakes about a dozen times a day, checks whether anything needs doing, and usually the answer is no. That "usually no" turned out to be the whole cost problem, and it took us longer than it should have to see it.&lt;/p&gt;

&lt;p&gt;An interval trigger has no idea whether there is work. It fires exactly as often on a dead Tuesday as during a busy hour. So if the first thing your wake path does is the full sweep, you pay full price for every empty tick. We counted six fires in about two hours and twenty minutes, and half produced nothing but a "nothing changed" log line. Each of those still ran the complete orientation: read state, check five external services, recompute derived metrics, refresh performance snapshots. Minutes of work and real API spend to conclude the world was exactly as we left it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put a cheap guard in front of the expensive path
&lt;/h2&gt;

&lt;p&gt;Before doing anything real, answer one question as cheaply as you can: has anything plausibly changed since last time?&lt;/p&gt;

&lt;p&gt;For us that is a single indexed query against our own activity log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;run_session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;action_log&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;run_session&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the last run was under twenty minutes ago, we make exactly one external check, the cheapest signal we have that new work exists. If it comes back empty, we write two rows to the log and exit. No scouting, no recomputation, no metrics refresh.&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;last_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last_activity_ts&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;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_run&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;cheapest_external_signal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# one HTTP call
&lt;/span&gt;        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monitor&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;no-op&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip&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;fast-exit, nothing changed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="c1"&gt;# ... otherwise fall through to the full pass
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guard reads local state first, because local state is free and the network is not. And the tiebreaker should be the single cheapest external call you have rather than a representative sample of your integrations. The goal is a yes or no answer for close to zero cost, not an accurate picture of the world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the threshold from how fast your input actually accrues
&lt;/h2&gt;

&lt;p&gt;Twenty minutes is not a magic number. It came from watching how our inbound behaves: replies arrive over hours, so two wakes fifteen minutes apart genuinely cannot differ. If your agent watches a build queue that changes every thirty seconds, twenty minutes is absurd. Match the window to the thing you are watching, and write down why you picked it, because the next person to read the code will assume it was arbitrary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not let the guard trust a cached answer it never verified
&lt;/h2&gt;

&lt;p&gt;This one is subtle enough that we shipped it without noticing.&lt;/p&gt;

&lt;p&gt;Our agent keeps a human-readable file of things that are blocked: expired sessions, missing permissions, failing integrations. That part is fine. The bug was that the agent started reading that file to decide what to skip. One entry said an API key lacked write scope, which was true on the day it was written. We skipped that integration on every run afterwards on the strength of the note.&lt;/p&gt;

&lt;p&gt;When we finally tested it live, the write succeeded on the first attempt. The key had been fine for who knows how long. Nothing had failed, so nothing had corrected the note, so the agent went on believing it.&lt;/p&gt;

&lt;p&gt;The rule we settled on is that a block is a fact with a timestamp rather than a permanent state. If a guard is going to skip work, it has to confirm the reason still holds right now, and that confirmation should be cheap enough that there is no excuse to skip it. A note tells humans what happened. It should never be an input to control flow.&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="c1"&gt;# wrong: the note decides
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;devto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;blocked_notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;devto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# right: the note informs, the live check decides
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;devto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;can_write&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;        &lt;span class="c1"&gt;# one API call
&lt;/span&gt;    &lt;span class="nf"&gt;note_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;devto&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;write scope missing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;devto&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;h2&gt;
  
  
  Make state durable per run, not per tick
&lt;/h2&gt;

&lt;p&gt;The other thing that bites scheduled agents is interruption. A cron-triggered process that dies halfway leaves no trace of how far it got, so the next tick starts from zero and redoes work that already succeeded. If any of that work had side effects, they happen twice.&lt;/p&gt;

&lt;p&gt;Persist progress at the step level, keyed by run, and have the wake path check for an unfinished run before starting a new one. Resuming a half-finished pass is almost always cheaper than repeating it, and it turns a crash from a correctness problem into a latency problem.&lt;/p&gt;

&lt;p&gt;This is also what separates an agent that can ask a human for approval from one that cannot. If approval lives in memory inside the process, the process dying takes the pending decision with it. If approval is durable state the run can be resumed into, someone can answer twenty minutes later from their phone and the work carries on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we landed
&lt;/h2&gt;

&lt;p&gt;Guard the expensive path with the cheapest check that can answer no. Read local state before touching the network. Confirm a block is still true instead of inheriting it from a note. Keep per-run state durable so an interrupted pass resumes.&lt;/p&gt;

&lt;p&gt;The agent is no smarter for any of this. The idle case just costs almost nothing now, and for us the idle case is most of them.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>architecture</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Human in the loop is not a switch: classify the commands</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Sun, 02 Aug 2026 23:43:21 +0000</pubDate>
      <link>https://dev.to/jacksonxly/human-in-the-loop-is-not-a-switch-classify-the-commands-13h2</link>
      <guid>https://dev.to/jacksonxly/human-in-the-loop-is-not-a-switch-classify-the-commands-13h2</guid>
      <description>&lt;p&gt;Human in the loop is usually implemented as a switch: approvals on, or approvals off. Both settings are wrong for an agent that runs shell commands, and you find that out the same way everyone does, which is by watching a long task die on its third &lt;code&gt;ls&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The stall looks like this. Your agent is working through something multi-step, it needs to look at a directory, the harness raises an approval, and the run stops dead waiting for a human who has gone to make coffee. Multiply that by forty tool calls and the agent is no longer autonomous, it is a very expensive interactive shell.&lt;/p&gt;

&lt;p&gt;So people reach for one of two fixes, and both make it worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two fixes that are not fixes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Turning the gate off.&lt;/strong&gt; Now &lt;code&gt;rm -rf&lt;/code&gt; runs unattended too. The gate was not the problem, and you have removed the only thing standing between a plausible-looking command and an irreversible action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrapping the tool in something else.&lt;/strong&gt; This is the one I see most: the agent stalls on the built-in shell tool, so someone routes it through a different tool layer and the stall goes away. It goes away because the new layer does not ask. You have not removed the approval, you have moved who is responsible for asking, and usually the answer is now nobody.&lt;/p&gt;

&lt;p&gt;The interrupt is not a bug. It is the gate doing its job. The bug is that the gate cannot tell &lt;code&gt;ls&lt;/code&gt; from &lt;code&gt;rm&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classify by reversibility, not by tool
&lt;/h2&gt;

&lt;p&gt;The useful axis is not which tool is being called, it is whether the call can be undone. Reads are recoverable by definition, since the worst case is that you read something and discard it. Writes, deletes, and anything that leaves the machine are not.&lt;/p&gt;

&lt;p&gt;So the gate gets a classifier, and the classifier decides who has to wake up:&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;READ_ONLY&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;ls&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;cat&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;head&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;tail&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;wc&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;stat&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;file&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;find&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;grep&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;rg&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;git status&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;git log&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;git diff&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;git show&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;git branch&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="n"&gt;RISKY_PREFIXES&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;rm&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;mv&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;dd&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;chmod&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;chown&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;kill&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;curl&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;wget&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;git push&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;git reset&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;npm publish&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;docker rm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Anything that lets one approved command smuggle in another.
&lt;/span&gt;&lt;span class="n"&gt;SHELL_OPERATORS&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;&amp;amp;&amp;amp;&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;||&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;|&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;;&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;`&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;$(&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;&amp;gt;&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;&amp;gt;&amp;gt;&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="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;needs_human&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# A command containing shell operators is not one command, it is several.
&lt;/span&gt;    &lt;span class="c1"&gt;# Do not try to reason about the pieces. Ask.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SHELL_OPERATORS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;READ_ONLY&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="c1"&gt;# Everything not explicitly known-safe requires a human, including
&lt;/span&gt;    &lt;span class="c1"&gt;# commands that merely look harmless.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about that function matter more than the word lists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is an allowlist, not a blocklist.&lt;/strong&gt; A blocklist is a bet that you thought of every dangerous command, and you did not. &lt;code&gt;RISKY_PREFIXES&lt;/code&gt; above is there for logging and for showing the user why something is being escalated; it is not what makes the decision. The decision is made by the final &lt;code&gt;return True&lt;/code&gt;, which is the deny-by-default line. Delete that line and the whole thing is decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It refuses to parse compound commands.&lt;/strong&gt; &lt;code&gt;ls &amp;amp;&amp;amp; rm -rf build&lt;/code&gt; starts with an allowlisted token. If your check is &lt;code&gt;startswith&lt;/code&gt;, you just approved a delete. You can go and write a real shell parser, or you can treat the presence of an operator as an automatic escalation, which is a two-line rule that does not have parser bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The asymmetry is what makes this safe
&lt;/h2&gt;

&lt;p&gt;Every classifier is wrong sometimes, so the question is what its errors cost.&lt;/p&gt;

&lt;p&gt;If the classifier calls a safe command risky, the user gets one unnecessary approval prompt. Annoying, bounded, visible.&lt;/p&gt;

&lt;p&gt;If it calls a risky command safe, something irreversible happens with nobody watching. Not bounded, and often not visible until later.&lt;/p&gt;

&lt;p&gt;Those two are not comparable, which is why the default branch has to be "ask" rather than "allow". You are not trying to build a classifier that is right. You are trying to build one whose mistakes all land on the cheap side, and then curating the allowlist until the cheap mistakes are rare enough to live with.&lt;/p&gt;

&lt;p&gt;This is also the reason the allowlist should be boring and explicit rather than clever. Every entry you add is a small, deliberate decision that this exact thing can run while you sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;We build recal, a local-first assistant on macOS, and its command tool works this way: read-only commands execute unattended, everything else raises an approval that carries the actual command text so the user is approving a thing rather than a category. Long tasks stopped stalling, and the approvals that remain are the ones worth reading.&lt;/p&gt;

&lt;p&gt;The honest catch is that the allowlist is maintenance. New tooling shows up, someone's workflow needs &lt;code&gt;jq&lt;/code&gt; or &lt;code&gt;kubectl get&lt;/code&gt;, and the list has to grow. There is no version of this where you write the classifier once. What you get instead is a system where the cost of being wrong is a prompt instead of a restore from backup, and that trade has been worth it every time.&lt;/p&gt;

&lt;p&gt;The other honest catch: this does nothing about a command that is individually safe and collectively terrible. Forty approved writes in the wrong directory is still forty approved writes. Reversibility classification bounds the blast radius of a single call, not of a plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are building one of these
&lt;/h2&gt;

&lt;p&gt;Start with deny-by-default and an allowlist of four or five read commands, then add entries when a real task trips on one. Log every escalation with the command text, because that log is what tells you which entries to add next. And resist the urge to make the classifier smart. The value here is not intelligence, it is that the failure mode is a question rather than a deletion.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and edited by a human. The allowlist-over-blocklist and compound-command guidance follows the security notes in Anthropic's bash tool documentation; the rest is what we shipped and what it cost us.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>security</category>
      <category>python</category>
    </item>
    <item>
      <title>Your cache_read_input_tokens is zero. Here is what silently did it.</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Thu, 30 Jul 2026 05:32:37 +0000</pubDate>
      <link>https://dev.to/jacksonxly/your-cachereadinputtokens-is-zero-here-is-what-silently-did-it-30gh</link>
      <guid>https://dev.to/jacksonxly/your-cachereadinputtokens-is-zero-here-is-what-silently-did-it-30gh</guid>
      <description>&lt;p&gt;Your &lt;code&gt;cache_read_input_tokens&lt;/code&gt; is zero. Here is the list of things that silently did it.&lt;/p&gt;

&lt;p&gt;You added &lt;code&gt;cache_control&lt;/code&gt;, the docs said up to 90% cheaper, and the counter still reads zero on every request. Nothing errored. There is no warning. The cache just never gets read.&lt;/p&gt;

&lt;p&gt;This is almost always one of a small number of specific mistakes, and they are all instances of a single rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule
&lt;/h2&gt;

&lt;p&gt;Prompt caching is a &lt;strong&gt;prefix match&lt;/strong&gt;. The cache key is the exact bytes of the rendered prompt up to each breakpoint. Any byte that changes anywhere in the prefix invalidates the cache for every breakpoint at or after that position.&lt;/p&gt;

&lt;p&gt;Render order is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tools  -&amp;gt;  system  -&amp;gt;  messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That ordering is the whole game. &lt;code&gt;tools&lt;/code&gt; render at position zero, so a change there invalidates everything. A breakpoint on the last &lt;code&gt;system&lt;/code&gt; block caches tools and system together.&lt;/p&gt;

&lt;p&gt;Once you internalise "prefix match, tools first", every item below is predictable rather than surprising.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit list
&lt;/h2&gt;

&lt;p&gt;Grep your prompt-assembly path for these. In my experience the first one accounts for most zero-hit reports.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In the prefix&lt;/th&gt;
&lt;th&gt;Why the cache never hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;datetime.now()&lt;/code&gt; / &lt;code&gt;Date.now()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Prefix differs on every request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;uuid4()&lt;/code&gt; / a request ID&lt;/td&gt;
&lt;td&gt;Same, and unfixable by retrying&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json.dumps(d)&lt;/code&gt; without &lt;code&gt;sort_keys=True&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Key order is not guaranteed, so the bytes move&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iterating a &lt;code&gt;set&lt;/code&gt; to build content&lt;/td&gt;
&lt;td&gt;Iteration order is not stable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session or user ID in the system prompt&lt;/td&gt;
&lt;td&gt;Per-user prefix, zero sharing across users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;if flag: system += ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every flag combination is a separate prefix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools=build_tools(user)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tools render first, so a per-user tool set caches for nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix in every case is the same shape: make it deterministic, move it after the last breakpoint, or delete it if it is not load bearing.&lt;/p&gt;

&lt;p&gt;A "current date" line in the system prompt is the classic. It feels harmless because it is one short line. It sits in front of everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does &lt;em&gt;not&lt;/em&gt; invalidate everything
&lt;/h2&gt;

&lt;p&gt;This is the part people over-correct on. There are three cache tiers, and a change only invalidates its own tier and below:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Messages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool definitions added, removed, reordered&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model switch&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web search or citations toggle&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System prompt content&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tool_choice&lt;/code&gt;, images, thinking on/off&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message content&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So you can flip &lt;code&gt;tool_choice&lt;/code&gt; per request, or toggle thinking, without losing the tools and system cache. Only tool-definition changes and model switches force a full rebuild.&lt;/p&gt;

&lt;p&gt;Two of those rows have an escape hatch, and they are not gated together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;System prompt content.&lt;/strong&gt; Instead of editing top-level &lt;code&gt;system&lt;/code&gt; mid-conversation, append a &lt;code&gt;{"role": "system", "content": "..."}&lt;/code&gt; message to &lt;code&gt;messages&lt;/code&gt;. It lands after the cached history, so the prefix survives. Available today on Claude Opus 5, Opus 4.8, Fable 5 and Mythos 5, with no beta header. Unsupported models return a 400, so catch it and fall back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool definitions.&lt;/strong&gt; On Claude Opus 5 there is a beta (&lt;code&gt;mid-conversation-tool-changes-2026-07-01&lt;/code&gt;) that adds and removes tools between turns via &lt;code&gt;tool_addition&lt;/code&gt; / &lt;code&gt;tool_removal&lt;/code&gt; blocks, without invalidating the prefix. Tools you plan to add must already be declared with &lt;code&gt;"defer_loading": true&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model switching has no escape hatch. Caches are model scoped. If you want a cheaper model for a sub-task, spawn a subagent rather than switching the main loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minimum is not monotonic
&lt;/h2&gt;

&lt;p&gt;There is a minimum cacheable prefix. Below it, nothing caches, you get no error, and &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; is just zero.&lt;/p&gt;

&lt;p&gt;The trap is that newer does not mean lower:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Minimum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Opus 5, Fable 5, Mythos 5&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.8, Sonnet 5, Sonnet 4.6, Sonnet 4.5&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.7&lt;/td&gt;
&lt;td&gt;2048&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.6, Opus 4.5, Haiku 4.5&lt;/td&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 3K-token prompt caches on Opus 5 and Opus 4.8 and silently does not on Opus 4.6 or Haiku 4.5. If you wrote a prompt off as "too short to cache" on an older model, it may be worth re-checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two timing gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The 20-block lookback.&lt;/strong&gt; Each breakpoint walks backward at most 20 content blocks looking for a prior cache entry. Agentic loops blow through that easily, because one turn can add a dozen tool_use and tool_result pairs. If a single turn adds more than 20 blocks, the next request's breakpoint does not find the previous cache and silently misses. Fix: place an intermediate breakpoint every ~15 blocks in long turns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrent requests all miss.&lt;/strong&gt; A cache entry becomes readable only once the first response &lt;em&gt;begins streaming&lt;/em&gt;. Fire N identical-prefix requests in parallel and all N pay full price, because none of them can read what the others are still writing. For fan-out, send one, await the first streamed token, then fire the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the counters honestly
&lt;/h2&gt;

&lt;p&gt;Three fields, and the third one misleads people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cache_creation_input_tokens&lt;/code&gt;: written to cache this request, billed at a premium&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache_read_input_tokens&lt;/code&gt;: served from cache, billed at roughly a tenth&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;input_tokens&lt;/code&gt;: the &lt;strong&gt;uncached remainder only&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total prompt size is the sum of all three. If your agent ran for an hour and &lt;code&gt;input_tokens&lt;/code&gt; shows 4K, the rest was served from cache. Check the sum, not the single field, before concluding anything about prompt size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it even worth it
&lt;/h2&gt;

&lt;p&gt;Reads cost about 0.1x. Writes cost 1.25x on the 5 minute TTL and 2x on the 1 hour TTL.&lt;/p&gt;

&lt;p&gt;So break-even depends on which TTL you picked. With 5 minutes, two requests pay for it (1.25 + 0.1 against 2 uncached). With 1 hour you need at least three (2 + 0.2 against 3). The 1 hour TTL keeps entries alive across gaps in bursty traffic, but the doubled write means it needs more reads to come out ahead.&lt;/p&gt;

&lt;p&gt;And if the first 1K tokens of your prompt genuinely differ every request, do not cache. You will pay the write premium for zero reads. Leaving it off is the correct answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-warming, and when not to
&lt;/h2&gt;

&lt;p&gt;You can pay the cache write up front with a &lt;code&gt;max_tokens: 0&lt;/code&gt; request. Prefill runs, the cache is written, and you get back &lt;code&gt;content: []&lt;/code&gt; with &lt;code&gt;stop_reason: "max_tokens"&lt;/code&gt; and zero output tokens billed.&lt;/p&gt;

&lt;p&gt;Put the breakpoint on the last block shared with the real request, usually the system prompt or the tool definitions. Not on the placeholder user message, and not via top-level automatic caching, which would key the cache to the placeholder.&lt;/p&gt;

&lt;p&gt;It is worth doing when first-request latency is user visible, the shared prefix is big enough that a cold write is noticeably slow, and there is a moment before traffic to fire it: app startup, worker boot, post-deploy.&lt;/p&gt;

&lt;p&gt;It is not worth doing when traffic is continuous. If real requests arrive more often than the TTL, they keep the cache warm on their own and a separate warm call is a pure extra write. Scheduled re-warms only make sense when your traffic has gaps longer than the TTL.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;max_tokens: 0&lt;/code&gt; is rejected alongside &lt;code&gt;stream: true&lt;/code&gt;, &lt;code&gt;thinking.type: "enabled"&lt;/code&gt;, &lt;code&gt;output_config.format&lt;/code&gt;, a forced &lt;code&gt;tool_choice&lt;/code&gt;, or inside a Batches request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debugging loop
&lt;/h2&gt;

&lt;p&gt;If the counter is zero, do this rather than adding more markers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Capture the fully rendered request body on two consecutive calls.&lt;/li&gt;
&lt;li&gt;Diff them.&lt;/li&gt;
&lt;li&gt;The first differing byte is your answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That diff is faster than reasoning about it, and it turns "caching is not working" into a one-line fix nearly every time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Behaviour described reflects the Claude API as documented in July 2026; caching internals move, so verify against current docs before you rely on a specific number.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>performance</category>
      <category>api</category>
    </item>
    <item>
      <title>Your baseline scored 0.000. That's a broken harness, not a result.</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Sat, 25 Jul 2026 03:34:51 +0000</pubDate>
      <link>https://dev.to/jacksonxly/your-baseline-scored-0000-thats-a-broken-harness-not-a-result-1mf3</link>
      <guid>https://dev.to/jacksonxly/your-baseline-scored-0000-thats-a-broken-harness-not-a-result-1mf3</guid>
      <description>&lt;p&gt;Your baseline scored 0.000? Before you publish the win, here is the checklist I now run, because a zero from a baseline is almost never a result. It is usually your harness.&lt;/p&gt;

&lt;p&gt;This week I watched a builder on r/Rag do something rare: he benchmarked his own memory library against plain RAG expecting a win, got three null results, and &lt;a href="https://old.reddit.com/r/Rag/comments/1v1zg98/i_spent_a_day_trying_to_prove_my_memory_layer/" rel="noopener noreferrer"&gt;published the confounds instead of the victory&lt;/a&gt;. Two of his bugs are so common, and so quiet, that I think every RAG or agent-memory benchmark should gate against them by construction. This post is the checklist that came out of that thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: your arms have different context budgets
&lt;/h2&gt;

&lt;p&gt;His first run compared a memory arm retrieving k=20 sentence-level hits against a BM25 arm returning whole sessions. Same "top-k" on paper. In characters, one arm got 1.3k of context and the other got 11.9k. BM25 looked dramatically better.&lt;/p&gt;

&lt;p&gt;Once he matched the budget, accuracy went 0.28 to 0.59 for the memory arms and the ranking flipped. The original result was a budget difference wearing a granularity costume.&lt;/p&gt;

&lt;p&gt;The fix is embarrassingly simple: print the context size, in characters or tokens, next to every accuracy number, per arm. If a comparison does not state budget parity, the number does not mean anything. Chunked-vs-whole-document comparisons are especially prone to this because "top-k" hides a 10x budget difference in plain sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the baseline that scored 0.000 with clean logs
&lt;/h2&gt;

&lt;p&gt;He ran a competing memory tool as a baseline. It scored 0.000. He re-ran it with a stronger extraction model. 0.000 again, logs clean. Very tempting to publish "competitor discards memories under load."&lt;/p&gt;

&lt;p&gt;It was his bug, twice. His harness truncated each session to 6,000 characters before ingestion, cutting off the injected evidence. And he was passing &lt;code&gt;limit=&lt;/code&gt; to an API whose parameter is &lt;code&gt;top_k=&lt;/code&gt;, so his setting was silently ignored.&lt;/p&gt;

&lt;p&gt;That second one deserves its own paragraph, because it is everywhere in this ecosystem: &lt;strong&gt;APIs that accept unknown kwargs without raising&lt;/strong&gt;. They make a broken configuration look like a clean loss. In a benchmark, treat any client that swallows unknown parameters as hostile. Do not trust that a parameter did something. Assert it: if you move &lt;code&gt;top_k&lt;/code&gt; from 5 to 20, the retrieved count has to move. If it does not, your harness is configuring nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three gates that make results trustworthy
&lt;/h2&gt;

&lt;p&gt;Out of that exchange came a harness structure I now consider the minimum for memory and RAG benchmarks. Three separate liveness gates, each with its own abort message, all pre-registered before the run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A positive control per arm.&lt;/strong&gt; Every arm gets a probe it cannot fail, built from the corpus's own ground truth. If any arm scores below a threshold you wrote down before the run, the whole benchmark aborts as "dead harness," not "clean loss." This is the gate that catches truncation bugs, auth failures, and empty stores. A zero is never evidence until this gate passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. An evidence-in-context ceiling.&lt;/strong&gt; For each probe, check whether the gold evidence is actually present in the retrieved context. Aggregated, this is the recall ceiling you report next to accuracy. In his broken run it was 3.5 percent. You cannot out-rank evidence that was never retrieved, and no reranker or prompt change will save a run whose ceiling is on the floor. Cheap to compute, needs no LLM calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A parameter-efficacy assertion.&lt;/strong&gt; For each knob the benchmark claims to vary, assert that observable behavior changes when the knob moves. This is the gate that catches the swallowed-kwargs class of bug, and it is the one nobody runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why three gates and not one
&lt;/h2&gt;

&lt;p&gt;The temptation is to fold these into a single sanity check. Do not. The whole value of a gate is that its failure names the broken layer. Evidence-in-context failing means retrieval never delivered. The positive control failing means the store or the answerer is dead. The efficacy assertion failing means your configuration is fiction. One merged gate tells you "something is wrong" and leaves the first hour of debugging still ahead of you.&lt;/p&gt;

&lt;p&gt;There is a general lesson under all of this. A benchmark harness is software that lies by default, because every bug it has produces a plausible-looking number instead of a crash. The discipline that fixes it is the same one that fixes agent evaluation generally: never trust a claim the system makes about itself unless something the system cannot touch verifies it. A test that has never been proven to fail will happily pass forever.&lt;/p&gt;

&lt;p&gt;Credit where it is due: the null-result writeup that prompted this is worth reading in full, including the part where he catches himself half-writing a false finding about a competitor before his own positive control saved him. Publishing that takes more spine than publishing a win.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build a local-first assistant for the Mac, which is why I spend my days in retrieval evals. No product pitch here; the checklist stands on its own.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Our Rust file watcher ate 23.6 GB of RAM, and our ignore rules never had a chance</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:40:17 +0000</pubDate>
      <link>https://dev.to/jacksonxly/our-rust-file-watcher-ate-236-gb-of-ram-and-our-ignore-rules-never-had-a-chance-49pn</link>
      <guid>https://dev.to/jacksonxly/our-rust-file-watcher-ate-236-gb-of-ram-and-our-ignore-rules-never-had-a-chance-49pn</guid>
      <description>&lt;p&gt;We build a local-first indexer for macOS. It watches the user's home folder so that when a file changes, we reindex just that file. Watching one project directory is a solved problem. Watching a real human's &lt;code&gt;$HOME&lt;/code&gt; is a different sport, and it broke us in a way that took a heap profiler to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;The app sat at roughly 25 GB of resident memory. Not a slow leak either. It climbed there shortly after launch and stayed. One core also pinned itself for about ten minutes at every boot.&lt;/p&gt;

&lt;p&gt;The obvious suspects were wrong. It was not the embedding model, and it was not our index. We had a careful ignore list (caches, &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;Library&lt;/code&gt;, build output), so surely we were not touching millions of files.&lt;/p&gt;

&lt;p&gt;We were not. But something else was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding
&lt;/h2&gt;

&lt;p&gt;We use the excellent &lt;code&gt;notify&lt;/code&gt; crate, specifically &lt;code&gt;notify-debouncer-full&lt;/code&gt;, which is what you reach for when you want raw filesystem events collapsed into something sane. The setup you copy from the README uses &lt;code&gt;RecommendedCache&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RecommendedCache&lt;/code&gt; resolves to &lt;code&gt;FileIdMap&lt;/code&gt;. From the docs, the debouncer "can optionally keep track of the file system IDs all files and stitches rename events together". That is a genuinely useful feature: on macOS FSEvents and on Windows, a rename shows up as two unrelated events, and pairing them requires knowing that the file at the old path and the file at the new path are the same inode.&lt;/p&gt;

&lt;p&gt;To do that, it has to know the file ID of every file under the watch root. So it walks the entire watch root and caches a &lt;code&gt;(PathBuf, FileId)&lt;/code&gt; for every entry it finds.&lt;/p&gt;

&lt;p&gt;Here is the part that mattered: &lt;strong&gt;that walk does not know about your ignore rules.&lt;/strong&gt; Our ignore list filters &lt;em&gt;events&lt;/em&gt;. The cache is built underneath that, from the watch root down. We told the debouncer to watch &lt;code&gt;$HOME&lt;/code&gt;, so it faithfully walked all of &lt;code&gt;$HOME&lt;/code&gt;, including everything we thought we had excluded, following symlinks as it went.&lt;/p&gt;

&lt;p&gt;Under &lt;code&gt;malloc_history&lt;/code&gt;, the damage was specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;23.6 GB of live heap in the cache&lt;/li&gt;
&lt;li&gt;4.16 GB of that in the hashbrown table itself&lt;/li&gt;
&lt;li&gt;19.4 GB spread across 54.2 million path allocations&lt;/li&gt;
&lt;li&gt;roughly 6.4 million files stat-ed at boot, which is the pinned core and the ten minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;54 million allocations to remember the inode of files we had explicitly said we did not care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and what it costs
&lt;/h2&gt;

&lt;p&gt;We switched the cache to &lt;code&gt;NoCache&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is not free, and it is worth being precise about the tradeoff rather than pretending we outsmarted the library. &lt;code&gt;FileIdMap&lt;/code&gt; exists for a reason: with &lt;code&gt;NoCache&lt;/code&gt;, the debouncer can no longer stitch rename events together. A rename stops arriving as "this moved from A to B" and degrades into an unrelated delete at A and a create at B.&lt;/p&gt;

&lt;p&gt;We could absorb that, for two reasons that are specific to our design:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Our indexer is idempotent. Indexing the same file twice converges to the same row, so a spurious create is cheap and harmless.&lt;/li&gt;
&lt;li&gt;We already run a rescan and reconcile pass that removes index entries whose files no longer exist. Deletions get collected there regardless of whether the watcher paired them correctly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So a rename becomes delete-plus-create, and the system settles into the right state on its own. If your indexer is not idempotent, or you have no reconcile pass, this trade is not available to you and you should fix the scope instead.&lt;/p&gt;

&lt;p&gt;Result: 24.8 GB down to 1.45 GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug we found on the way
&lt;/h2&gt;

&lt;p&gt;While measuring, we caught something else. When FSEvents drops events (it does this under load, by design, and tells you so), the debouncer emits a rescan. Our code fanned that out as one rescan per &lt;em&gt;configured&lt;/em&gt; root.&lt;/p&gt;

&lt;p&gt;Our roots were nested. We watched &lt;code&gt;$HOME&lt;/code&gt;, and we also watched &lt;code&gt;Documents&lt;/code&gt;, &lt;code&gt;Downloads&lt;/code&gt;, and &lt;code&gt;Desktop&lt;/code&gt; because they were configured separately. So a single dropped event triggered four concurrent, roughly &lt;code&gt;$HOME&lt;/code&gt;-scale re-walks, and every file under the nested roots got embedded twice. You could see it plainly in the logs: two &lt;code&gt;EmbeddingsGenerated&lt;/code&gt; lines per document.&lt;/p&gt;

&lt;p&gt;The fix was to de-nest the roots into independent subtrees before emitting any rescan, so overlapping configuration collapses into one walk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson worth stealing
&lt;/h2&gt;

&lt;p&gt;Library defaults are calibrated for the common case, and the common case is a project folder with a few thousand files. Nothing in the API warns you, because nothing is wrong with the API. &lt;code&gt;FileIdMap&lt;/code&gt; is correct. It is correct at 5,000 files and it is a memory bomb at 6.4 million, and the difference is entirely in what you point it at.&lt;/p&gt;

&lt;p&gt;So, two things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know what your watcher caches before you widen the scope.&lt;/strong&gt; Filtering events is not the same as filtering what the library indexes internally. Ours sat below our filter, where we could not see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure the heap, do not reason about it.&lt;/strong&gt; We would never have found 54.2 million path allocations by reading code. &lt;code&gt;malloc_history&lt;/code&gt; found it in one pass. Every hour we spent theorizing about the embedding model was an hour we did not spend attaching a profiler.&lt;/p&gt;

&lt;p&gt;If you are watching a user's whole home directory, assume every default in your stack was written by someone who was not.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>macos</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Point any app at a local LLM on your Mac (OpenAI-compatible endpoints)</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Fri, 10 Jul 2026 00:17:10 +0000</pubDate>
      <link>https://dev.to/jacksonxly/point-any-app-at-a-local-llm-on-your-mac-openai-compatible-endpoints-4o8l</link>
      <guid>https://dev.to/jacksonxly/point-any-app-at-a-local-llm-on-your-mac-openai-compatible-endpoints-4o8l</guid>
      <description>&lt;p&gt;Most apps that grew an "AI" feature in the last two years talk to one of a handful of cloud APIs, and almost all of them speak the same dialect: the OpenAI Chat Completions format. That one detail is the reason you can pull the cloud out and run the whole thing locally on a Mac without the app ever noticing.&lt;/p&gt;

&lt;p&gt;Here is the trick, why it works, and the gotchas that bite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one interface everything agrees on
&lt;/h2&gt;

&lt;p&gt;OpenAI's &lt;code&gt;/v1/chat/completions&lt;/code&gt; endpoint became the de facto standard. So when an app lets you "use your own key" or "set a custom base URL," it is almost always going to POST to &lt;code&gt;{base_url}/chat/completions&lt;/code&gt; with a JSON body of messages and read back the same shape. It does not care what is on the other end, only that the response matches.&lt;/p&gt;

&lt;p&gt;Local runners leaned into this. Both popular Mac ones expose exactly that endpoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ollama&lt;/strong&gt; serves an OpenAI-compatible API at &lt;code&gt;http://localhost:11434/v1&lt;/code&gt; (its native API lives on &lt;code&gt;/api&lt;/code&gt;, but the &lt;code&gt;/v1&lt;/code&gt; path speaks the OpenAI dialect).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LM Studio&lt;/strong&gt; has a built-in server you switch on from the Developer tab, serving on &lt;code&gt;http://localhost:1234/v1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "make this app local" usually reduces to: point its base URL at one of those, put any non-empty string where it wants an API key, and pick a model you have pulled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 60-second version
&lt;/h2&gt;

&lt;p&gt;Ollama:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;ollama        &lt;span class="c"&gt;# or the .dmg from ollama.com&lt;/span&gt;
ollama serve &amp;amp;             &lt;span class="c"&gt;# server on :11434&lt;/span&gt;
ollama pull llama3.1:8b    &lt;span class="c"&gt;# pull a model once&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm it speaks OpenAI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:11434/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "llama3.1:8b",
    "messages": [{"role": "user", "content": "say hi in 3 words"}]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns a &lt;code&gt;choices[0].message.content&lt;/code&gt;, any OpenAI-compatible client can use it. In the app, set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Base URL: &lt;code&gt;http://localhost:11434/v1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;API key: &lt;code&gt;ollama&lt;/code&gt; (or literally anything; it is ignored)&lt;/li&gt;
&lt;li&gt;Model: &lt;code&gt;llama3.1:8b&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LM Studio is the same idea with a GUI: load a model, toggle the server on, and use base URL &lt;code&gt;http://localhost:1234/v1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pointing real tools at it
&lt;/h2&gt;

&lt;p&gt;The pattern shows up everywhere once you look for it. The official OpenAI SDKs are the clearest example: change one field.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llama3.1:8b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&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;role&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;user&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;content&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;summarize this in one line: ...&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="nf"&gt;print&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="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code, cloud or local. Only the &lt;code&gt;base_url&lt;/code&gt; changed. In JavaScript it is the &lt;code&gt;baseURL&lt;/code&gt; option; in a lot of CLI and editor tools it is an &lt;code&gt;OPENAI_BASE_URL&lt;/code&gt; environment variable. Some end-user apps expose it too: DEVONthink 4, for instance, lets its Chat point at Ollama or LM Studio directly, so search and summarize run over your own documents with nothing leaving the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest gotchas
&lt;/h2&gt;

&lt;p&gt;It is not always drop-in. The places it breaks, roughly in order of how often they catch people:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context window.&lt;/strong&gt; A local model often defaults to a small context (Ollama defaults to 2048 tokens unless you raise &lt;code&gt;num_ctx&lt;/code&gt;). If an app sends a big document and the model only reads the last 2k tokens, it looks like the model "ignored" your content. Raise the context in the model config, or send smaller chunks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing features.&lt;/strong&gt; Some apps assume function calling, JSON mode, or vision. Not every local model or runner supports all of them, and the failure is often silent. Check the runner's compatibility notes for the specific feature you depend on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming quirks.&lt;/strong&gt; Most local servers stream fine, but a few clients expect particular SSE framing. If a response hangs, turn streaming off in the client as a test to isolate it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model quality.&lt;/strong&gt; A 7-8B model is not GPT-class. For classification, summarizing, tagging, and search over your own material it is often plenty; for long chains of reasoning it will disappoint. Match the job to the model rather than expecting parity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RAM.&lt;/strong&gt; Everything is bounded by unified memory on a Mac. 16GB comfortably runs a capable 7-8B model; below that, stay in the 3B range and keep the context modest.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;Two reasons that actually matter. It is free after the download, so there is no per-token meter running while you iterate on a prompt fifty times. And nothing leaves the machine, which for anything sensitive (client data, personal notes, a private codebase) is the difference between "I can use AI on this" and "I am not allowed to."&lt;/p&gt;

&lt;p&gt;The best part is that because it is one interface, you do not have to commit. Keep a cloud base URL for the genuinely hard reasoning and a local one for the bulk, private, high-volume work, and switch between them by changing a single string.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and edited by a human. Endpoint details reflect public docs as of July 2026 and move quickly, so check each project's current docs for exact paths and defaults.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>mac</category>
      <category>productivity</category>
    </item>
    <item>
      <title>MLX vs GGUF on Apple Silicon: which local LLM format should you actually use?</title>
      <dc:creator>Jackson Ly</dc:creator>
      <pubDate>Tue, 07 Jul 2026 01:16:53 +0000</pubDate>
      <link>https://dev.to/jacksonxly/mlx-vs-gguf-on-apple-silicon-which-local-llm-format-should-you-actually-use-53gj</link>
      <guid>https://dev.to/jacksonxly/mlx-vs-gguf-on-apple-silicon-which-local-llm-format-should-you-actually-use-53gj</guid>
      <description>&lt;p&gt;If you run local models on a Mac, you eventually hit the same fork in the road: the same model is available as a GGUF file and as an MLX version, and something has to tell you which one to download. The short answer is that MLX is faster on Apple Silicon and GGUF goes everywhere. The useful answer is knowing when that trade actually matters, because for a lot of setups it does not.&lt;/p&gt;

&lt;p&gt;Here is the practical version, with the reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two formats actually are
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GGUF&lt;/strong&gt; is a single self-contained file. Weights, tokenizer, metadata, and quantization parameters are all bundled into one portable blob that llama.cpp (and everything built on it) can load anywhere: Mac, Linux, Windows, CPU, CUDA, Metal. That portability is the whole point of the format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MLX&lt;/strong&gt; is Apple's array framework, not a file. An MLX model is a directory of safetensors files plus a config that the MLX runtime reads directly. It is built to run on Apple Silicon and quantize natively against the unified memory pool. It does not leave Apple Silicon, full stop.&lt;/p&gt;

&lt;p&gt;So this is not really "two file formats." It is one portable format and one Apple-native runtime that happen to ship the same model weights.&lt;/p&gt;

&lt;h2&gt;
  
  
  The performance gap is real but bounded
&lt;/h2&gt;

&lt;p&gt;On the same Mac, at the same quantization level, MLX runs roughly 15 to 40 percent faster than GGUF and uses about 10 percent less memory. The speedup comes from MLX being compiled for Apple Silicon and operating directly on the unified memory pool, where the CPU and GPU share one block of RAM and every gigabyte is usable as model memory.&lt;/p&gt;

&lt;p&gt;There is one quality nuance worth knowing. At 4-bit, GGUF's &lt;code&gt;Q4_K_M&lt;/code&gt; uses mixed precision inside each layer, so it can hold quality slightly better than a naive 4-bit MLX quant. It is a small difference, but if you are quantizing aggressively on a memory-tight machine, it is the kind of thing that shows up in outputs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GGUF&lt;/th&gt;
&lt;th&gt;MLX&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Portable single file&lt;/td&gt;
&lt;td&gt;Apple framework, safetensors dir&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on&lt;/td&gt;
&lt;td&gt;Mac, Linux, Windows, CPU, CUDA, Metal&lt;/td&gt;
&lt;td&gt;Apple Silicon only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed on M-series&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;~15 to 40% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;~10% less&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-bit quality&lt;/td&gt;
&lt;td&gt;Slightly better (Q4_K_M mixed precision)&lt;/td&gt;
&lt;td&gt;Fine, marginally behind at 4-bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Portability, ecosystem, cross-platform&lt;/td&gt;
&lt;td&gt;Raw throughput on Apple Silicon&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Tool support is the part people get wrong
&lt;/h2&gt;

&lt;p&gt;The format you should pick is partly decided by the tool you already use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LM Studio&lt;/strong&gt; has had an MLX backend since late 2024 and also runs GGUF through its bundled llama.cpp. So on LM Studio you genuinely get to choose per model, and picking the MLX build on an M-series chip is usually free speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ollama&lt;/strong&gt; ran GGUF through llama.cpp's Metal path for a long time. Its 0.19 preview added an optional MLX backend that reports roughly double the old speed, but it targets Macs with 32GB or more of unified memory. On a 16GB machine you stay on the GGUF/Metal path, so the MLX question does not even come up for you yet.&lt;/p&gt;

&lt;p&gt;That 32GB line matters more than the raw benchmark. A lot of "MLX is 2x faster" claims quietly assume a machine most people do not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The portability catch that bites later
&lt;/h2&gt;

&lt;p&gt;MLX being Apple-only is fine until it is not. If you are building something that might need a CUDA fallback, a Linux server, or a Windows user, and you shipped MLX as your only build, you are re-quantizing under pressure the day that requirement lands. GGUF would have just run.&lt;/p&gt;

&lt;p&gt;This is the real decision axis, and it is not about speed. It is about how long your setup has to live and where it has to run. A weekend project that only ever runs on your own MacBook has no reason not to take the MLX speedup. Infrastructure that will outlive any single runtime should default to GGUF, or ship both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision, compressed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Personal use on an M-series Mac with 32GB or more:&lt;/strong&gt; take MLX. It is faster and lighter and you lose nothing you will miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16GB Mac, or you just want the simplest path:&lt;/strong&gt; GGUF. You are likely on the GGUF path anyway, and the portability is free insurance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform, a server, or anything that might need CUDA later:&lt;/strong&gt; GGUF, or build both. Do not paint yourself into an Apple-only corner for a 20 percent speedup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are not sure:&lt;/strong&gt; GGUF. It runs everywhere, and you can always add the MLX build later when you know you want it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest summary is that both are good, and on a modern Mac you can usually try each in a couple of minutes with the same tool. Speed is the easy axis to measure and the wrong one to optimize first. Portability is the one you only notice when it is gone.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and edited by a human. Format details reflect public information as of July 2026 and move quickly (Ollama's MLX backend in particular is young), so check each tool's current release notes before you rely on a specific number.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>apple</category>
    </item>
  </channel>
</rss>
