<?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: Maksim Danilchenko</title>
    <description>The latest articles on DEV Community by Maksim Danilchenko (@dmaxdev).</description>
    <link>https://dev.to/dmaxdev</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%2F3851903%2F271b9f0d-273c-44e2-a2c7-0d4ec886b1c5.jpeg</url>
      <title>DEV Community: Maksim Danilchenko</title>
      <link>https://dev.to/dmaxdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dmaxdev"/>
    <language>en</language>
    <item>
      <title>I Benchmarked My AI Carb Counter on 400 Meal Photos: 8 Models, 1 Holdout Set</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Thu, 08 Oct 2026 09:09:15 +0000</pubDate>
      <link>https://dev.to/dmaxdev/i-benchmarked-my-ai-carb-counter-on-400-meal-photos-8-models-1-holdout-set-21kh</link>
      <guid>https://dev.to/dmaxdev/i-benchmarked-my-ai-carb-counter-on-400-meal-photos-8-models-1-holdout-set-21kh</guid>
      <description>&lt;p&gt;I build &lt;a href="https://soba-app.com/" rel="noopener noreferrer"&gt;Soba&lt;/a&gt;, an iPhone app that looks at a photo of a meal and estimates the carbs in it. People with type 1 diabetes use numbers like that to dose insulin, so "it seems to work" wasn't a good enough answer. I ran it on 400 meal photos from two public research datasets where the carbs were weighed or recorded, and published the method, the misses and the per-meal data on the &lt;a href="https://soba-app.com/accuracy/" rel="noopener noreferrer"&gt;Soba accuracy page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is the engineering side of that test: how the benchmark was set up, which of eight models won, and the bugs a production-path test turns up that a separate eval harness can miss. The &lt;a href="https://www.danilchenko.dev/posts/ai-carb-counting-accuracy/" rel="noopener noreferrer"&gt;full write-up on my blog&lt;/a&gt; has the per-meal-size breakdown and the comparison with published ChatGPT studies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The photos come from &lt;a href="https://github.com/google-research-datasets/Nutrition5k" rel="noopener noreferrer"&gt;Nutrition5k&lt;/a&gt; (Google cafeteria plates, every ingredient weighed, fixed overhead camera) and &lt;a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC10708545/" rel="noopener noreferrer"&gt;SNAPMe&lt;/a&gt; (95 people in the US photographing their own meals and logging them in a food record). The first is clean, controlled data. The second looks like what users actually shoot.&lt;/p&gt;

&lt;p&gt;A few choices did most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A script with a fixed seed drew 25 meals from each quarter of the carb range, per dataset, so no part of the range ended up under-sampled by chance.&lt;/li&gt;
&lt;li&gt;Cafeteria plates photographed within the same ten minutes often look nearly identical, so the script capped how many it took from one ten-minute window. The confidence intervals come from a bootstrap that resamples whole clusters (all photos from one SNAPMe participant, or from one ten-minute cafeteria window), because near-duplicate photos aren't independent samples.&lt;/li&gt;
&lt;li&gt;There are two disjoint sets of 200. All error analysis and prompt work happened on the first set; the second stayed untouched until a result needed confirming.&lt;/li&gt;
&lt;li&gt;Every photo was downscaled to 1,024 px on the long side like the app does and sent through the same server code, with the same instructions and model settings. There's no eval-only prompt.&lt;/li&gt;
&lt;li&gt;The main metric is the share of meals within 10 g of the reference carbs, roughly one bread unit. Failed scans count as misses (there were none).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The results in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Meals&lt;/th&gt;
&lt;th&gt;Within 10 g&lt;/th&gt;
&lt;th&gt;Median error&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Photo only, all meals&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;68.5%&lt;/td&gt;
&lt;td&gt;5.2 g&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo + text hint&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;72.8%&lt;/td&gt;
&lt;td&gt;4.5 g&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo only, cafeteria (Nutrition5k)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;77.5%&lt;/td&gt;
&lt;td&gt;3.9 g&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo + depth data, cafeteria&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;82.0%&lt;/td&gt;
&lt;td&gt;3.1 g&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo only, own photos (SNAPMe)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59.5%&lt;/td&gt;
&lt;td&gt;6.8 g&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo only, meals with 60 g+ carbs&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;19.0%&lt;/td&gt;
&lt;td&gt;25.4 g&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headline 68.5% has a 95% interval of 64.0% to 72.9%. The last row shows the main failure. In 38 of the 42 largest meals the estimate was too low, with the median estimate at 64% of the real amount. In 30 of those 38, the model judged the food to weigh at least 15% less than it did, while its carbs per 100 g were usually close.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foyqmlh5xyrg71ac6827o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foyqmlh5xyrg71ac6827o.png" alt="Scatter plot of 400 meals: actual carbs vs Soba's estimate. Most points sit inside the ±10 g band up to about 50 g; above 60 g most fall below it." width="800" height="905"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every meal in the test. The shaded band is ±10 g; points below the dashed line are underestimates.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The per-meal CSV is public, so you can check the headline numbers yourself with the standard library:&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;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;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;https://soba-app.com/accuracy/soba-carb-benchmark-2026-10.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&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;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DictReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TextIOWrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;soba_total_carbs_g&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;float&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reference_total_carbs_g&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;within 10 g: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;errs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;median error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; g&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;within 10 g: 68.5%
median error: 5.2 g
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Eight models, and why the winner didn't ship
&lt;/h2&gt;

&lt;p&gt;I ran eight models on the first 200 meals with Soba's prompt, one run each through OpenRouter, with the request settings Soba had before October 2026. These were separate runs, so production's numbers differ slightly from the table above:&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;Within 10 g&lt;/th&gt;
&lt;th&gt;Median time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;73.0%&lt;/td&gt;
&lt;td&gt;3.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;71.5%&lt;/td&gt;
&lt;td&gt;4.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash-Lite&lt;/td&gt;
&lt;td&gt;69.0%&lt;/td&gt;
&lt;td&gt;2.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.6 Flash (production)&lt;/td&gt;
&lt;td&gt;67.5%&lt;/td&gt;
&lt;td&gt;3.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Luna&lt;/td&gt;
&lt;td&gt;65.0%&lt;/td&gt;
&lt;td&gt;6.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Luna Pro&lt;/td&gt;
&lt;td&gt;63.0%&lt;/td&gt;
&lt;td&gt;11.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6.1 Sol&lt;/td&gt;
&lt;td&gt;58.0%&lt;/td&gt;
&lt;td&gt;13.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;53.0%&lt;/td&gt;
&lt;td&gt;6.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gemini 3.7 Flash beat production by 5.5 points. That's enough of a lead to make a switch look obvious. But when you pick the best of eight on the same 200 samples, part of the winner's lead is luck, so I ran both models on the untouched second set. They tied at 70.5%, and 3.7 Flash costs more. Production stayed on Gemini 3.6 Flash.&lt;/p&gt;

&lt;p&gt;Two more rows need a footnote. Claude Sonnet 5.5 returned an empty food item for 41 of the 200 photos through the provider OpenRouter routed it to; on the other 159 photos it scored 66.7%, right in the pack. If the harness had silently dropped empty answers, that model would have looked competitive, and the 41 empty answers would never have shown up. And GPT-6.1 Sol overestimated carbs by 10 g on average, a bias you'd only notice by looking at signed errors.&lt;/p&gt;

&lt;p&gt;Letting Gemini 3.6 Flash reason longer raised the share within 10 g by 4.1 points over all 400 meals, but the share within 5 g and the number of errors above 30 g stayed the same, and each scan took 6.6 seconds instead of 3.5. More thinking pushed medium errors under the line and did nothing for the large ones. I kept the fast setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bugs that only a full run finds
&lt;/h2&gt;

&lt;p&gt;The test pushed 3,614 requests through production code. Two problems fell out that normal traffic never surfaced:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The fallback model couldn't run.&lt;/strong&gt; Soba sent a &lt;code&gt;temperature&lt;/code&gt; parameter that the OpenAI backup didn't accept, and the request allowed only providers that support every parameter in it (OpenRouter's &lt;a href="https://openrouter.ai/docs/guides/routing/provider-selection" rel="noopener noreferrer"&gt;&lt;code&gt;require_parameters&lt;/code&gt;&lt;/a&gt; option). So the OpenAI backup would have failed on every scan the moment Gemini went down. Removing the parameter moved the main model from 68.9% to 68.5%, inside run-to-run noise. The backups are now Gemini 3.7 Flash and GPT-6 Luna.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repetition loops.&lt;/strong&gt; In 9 of 3,614 requests (0.25%), Gemini repeated one phrase over and over, and the scan took 35 to 87 seconds. A cap on answer length now cuts those off early.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A spot check of ten photos wouldn't have caught either one. The loops only appeared in the latency tail of 3,614 requests, and the dead fallback only when the backup itself was called.&lt;/p&gt;

&lt;h2&gt;
  
  
  What extra inputs added
&lt;/h2&gt;

&lt;p&gt;Since the dominant error was portion weight, I tested what extra inputs add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A text hint naming the foods (copied from the dataset records, no amounts) lifted the share within 10 g from 68.5% to 72.8%. It helped most on ambiguous photos, like a coffee with creamer that Soba had read as caramel sauce (162 g estimated without the hint, 5.5 g with it, 2.7 g in the food record). It didn't fix the main failure: large meals were still underestimated in 37 of 42 cases with a hint. The gain was also lopsided between the two sets (8.0 points vs 0.5), and real users won't type as precisely, so I treat it as an upper bound.&lt;/li&gt;
&lt;li&gt;Depth data lets the app tell the model how wide the frame is in centimeters, on iPhones with LiDAR or multiple rear cameras. Replayed from Nutrition5k's depth recordings on its 200 cafeteria photos, it lifted the share within 10 g from 77.5% to 82.0%. Large meals were still underestimated in 12 of 13 cases, and angled photos haven't been measured yet.&lt;/li&gt;
&lt;li&gt;The weight editor in the UI is the fallback for big plates. The app shows a weight range with each estimate and recalculates when the user enters a real weight. The real plate weight fell inside the shown range on 97 of 200 weighed plates, about half, so treat the range as a guide; typing in a weight from a kitchen scale is the reliable fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways for anyone benchmarking a vision LLM
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Keep a holdout set and don't touch it while tuning. Mine stopped a model switch that the first-round numbers made look obvious.&lt;/li&gt;
&lt;li&gt;Break results down by the size of what you're estimating. The 68.5% average hid a 19% result on the biggest meals.&lt;/li&gt;
&lt;li&gt;Count failures as misses, and look at signed errors. Empty answers and systematic bias both disappear in a careless average.&lt;/li&gt;
&lt;li&gt;Run the eval through the production code path. Mine found a dead fallback and a 0.25% latency tail that a separate eval harness could easily have missed.&lt;/li&gt;
&lt;li&gt;Find the bottleneck before you shop for models. Here it was portion weight on large plates, and the inputs that carry scale (depth, a weight the user enters) go after it more directly than a model swap. Even depth left 12 of 13 large meals underestimated.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full method, the limits (US-only meals, overhead camera on one dataset, self-reported references on the other) and the 400-row CSV are on the &lt;a href="https://soba-app.com/accuracy/" rel="noopener noreferrer"&gt;Soba accuracy page&lt;/a&gt;. The &lt;a href="https://www.danilchenko.dev/posts/ai-carb-counting-accuracy/" rel="noopener noreferrer"&gt;blog version&lt;/a&gt; has the meal-size table and how the numbers compare with published ChatGPT-4o and ChatGPT-5 studies.&lt;/p&gt;

&lt;p&gt;Soba isn't a medical device. Its numbers are estimates: check them against labels and a scale, and follow your diabetes care plan.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>testing</category>
    </item>
    <item>
      <title>LLM Food Recognition in Production: What Shipping soba Taught Me</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:48:52 +0000</pubDate>
      <link>https://dev.to/dmaxdev/llm-food-recognition-in-production-what-shipping-soba-taught-me-146f</link>
      <guid>https://dev.to/dmaxdev/llm-food-recognition-in-production-what-shipping-soba-taught-me-146f</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I spent the last few months building &lt;a href="https://apps.apple.com/cy/app/soba-carb-glycemic-index/id6792708725" rel="noopener noreferrer"&gt;soba&lt;/a&gt;, an iOS app that photographs a meal and returns carbs, glycemic index, and portion weights for people who count carbohydrates. The recognition backend went through one model migration, one full prompt rewrite, and a stack of validation code. Three things carried almost all of the improvement: picking the model with a benchmark instead of &lt;a href="https://www.danilchenko.dev/posts/vibe-coding/" rel="noopener noreferrer"&gt;vibes&lt;/a&gt;, forcing strict JSON Schema through OpenRouter, and rewriting the prompt around scene scale rather than food identity. Model choice turned out to be the smallest lever of the three. Every model I tested misjudges portion weight by 25–35%, so the UX for correcting weight beats another round of model shopping.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fact&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Verified&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best nutrition MAPE on DiningBench&lt;/td&gt;
&lt;td&gt;24.45% — Gemini-3-Pro (reported)&lt;/td&gt;
&lt;td&gt;arXiv:2604.10425&lt;/td&gt;
&lt;td&gt;Aug 29, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5 nutrition MAPE on same bench&lt;/td&gt;
&lt;td&gt;32.17% (reported)&lt;/td&gt;
&lt;td&gt;arXiv:2604.10425&lt;/td&gt;
&lt;td&gt;Aug 29, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-pro-preview price&lt;/td&gt;
&lt;td&gt;$2 / $12 per 1M tokens&lt;/td&gt;
&lt;td&gt;OpenRouter&lt;/td&gt;
&lt;td&gt;Aug 29, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.6-flash price&lt;/td&gt;
&lt;td&gt;$1.50 / $7.50 per 1M tokens&lt;/td&gt;
&lt;td&gt;OpenRouter&lt;/td&gt;
&lt;td&gt;Aug 29, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per scan (my prod)&lt;/td&gt;
&lt;td&gt;$0.005–0.01&lt;/td&gt;
&lt;td&gt;soba server billing&lt;/td&gt;
&lt;td&gt;Aug 29, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan latency (my prod)&lt;/td&gt;
&lt;td&gt;2.6–6 s&lt;/td&gt;
&lt;td&gt;soba server logs&lt;/td&gt;
&lt;td&gt;Aug 29, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What soba does
&lt;/h2&gt;

&lt;p&gt;The app is aimed at people with type 1 diabetes: carb counting several times a day, every day. You point the camera at a plate, and a few seconds later you get a list of items with weights, net carbs per 100 g, and a glycemic index estimate. There's a barcode path for packaged food too. Under the hood it's a Go server (&lt;code&gt;internal/recognize/&lt;/code&gt;) that sends the photo to a multimodal LLM and turns the answer into something a diabetic can act on.&lt;/p&gt;

&lt;p&gt;That framing shaped every technical decision. A person dosing insulin needs grams of digestible carbohydrate, an honest error bar, and no surprises when the model has a bad day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the model: benchmark first, then a live A/B
&lt;/h2&gt;

&lt;p&gt;I picked the starting model off a benchmark instead of marketing pages. &lt;a href="https://arxiv.org/abs/2604.10425" rel="noopener noreferrer"&gt;DiningBench&lt;/a&gt; evaluates 29 models on 3,021 dishes for exactly this task. Gemini-3-Pro led both fine-grained dish classification and nutrition estimation, with a 24.45% nutrition MAPE against 32.17% for GPT-5. That settled the starting lineup: soba launched on &lt;code&gt;gemini-3.1-pro-preview&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then I A/B-tested &lt;code&gt;gemini-3.6-flash&lt;/code&gt; against the pro model on the live recognition path. Same prompt, same photos. Quality differences never rose above the noise floor of gram estimates. What did change:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;gemini-3.1-pro-preview&lt;/th&gt;
&lt;th&gt;gemini-3.6-flash&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost per scan&lt;/td&gt;
&lt;td&gt;~$0.012&lt;/td&gt;
&lt;td&gt;~$0.005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;7.2–8.3 s&lt;/td&gt;
&lt;td&gt;2.6–6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing (per 1M tokens)&lt;/td&gt;
&lt;td&gt;$2 in / $12 out&lt;/td&gt;
&lt;td&gt;$1.50 in / $7.50 out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle status&lt;/td&gt;
&lt;td&gt;preview, can vanish&lt;/td&gt;
&lt;td&gt;stable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A scan that costs 58% less and returns in as little as a third of the time, at the same practical accuracy, was an easy call. The lifecycle column mattered more than it looks, too: preview models get deprecated whenever the vendor decides, and a health-adjacent app can't afford to wake up to a dead endpoint.&lt;/p&gt;

&lt;p&gt;All of these models (pro, flash, GPT) miss portion weight by 25–35%. That error dwarfs any gap between frontier models on food identity. The accuracy budget went into scale anchoring in the prompt and a weight-editing UX in the app, and I stopped following model releases with any anxiety — the opposite of what I found &lt;a href="https://www.danilchenko.dev/posts/gpt-claude-gemini-coding/" rel="noopener noreferrer"&gt;comparing frontier models for coding&lt;/a&gt;, where the gaps are real. A &lt;a href="https://doi.org/10.3390/nu18122017" rel="noopener noreferrer"&gt;multi-dataset study in Nutrients&lt;/a&gt; reports the same pattern, with prompt design moving nutrition accuracy as much as a model swap, and portion size has been the weak link in photo-based estimation &lt;a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC6246963/" rel="noopener noreferrer"&gt;since long before LLMs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One more model decision that saves real money: not every subtask deserves the full recognition call. Estimating the GI of a barcode-scanned product is a lookup against well-known food knowledge, so it runs on the fast flash model at temperature 0 and costs a rounding error compared to a photo scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API call, hardened
&lt;/h2&gt;

&lt;p&gt;Everything goes through OpenRouter, and the request body carries most of the production hardening. Here's the shape of it, abbreviated:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"models"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"google/gemini-3.6-flash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google/gemini-3.5-flash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai/gpt-5.1"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;recognition prompt, below&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Item names and notes language: German.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;The photo is approximately 31 cm wide at the distance of the food.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Analyze this photo."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"data:image/jpeg;base64,..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasoning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"effort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json_schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"json_schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"meal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"require_parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data_collection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deny"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u_9f3ab2c1"&lt;/span&gt;&lt;span class="w"&gt;
&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;Line by line, the parts that earn their place:&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;models&lt;/code&gt; array is the whole failover story. Primary is &lt;code&gt;gemini-3.6-flash&lt;/code&gt;; if Google has an incident, OpenRouter walks down to &lt;code&gt;gemini-3.5-flash&lt;/code&gt; and then across vendors to &lt;code&gt;openai/gpt-5.1&lt;/code&gt;. One line of config replaces a routing layer I never had to write, and a provider outage doesn't reach the user.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://openrouter.ai/docs/features/structured-outputs" rel="noopener noreferrer"&gt;Structured outputs&lt;/a&gt; with &lt;code&gt;strict: true&lt;/code&gt; mean the response is guaranteed to match my JSON Schema, so parsing is literally &lt;code&gt;json.Unmarshal&lt;/code&gt; and nothing else. Strict mode has sharp edges: every field must be listed in &lt;code&gt;required&lt;/code&gt;, and every object needs &lt;code&gt;additionalProperties: false&lt;/code&gt;. Miss either and the request is rejected before any tokens flow. The abbreviated schema for a meal item looks like this:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"name_en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grams"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grams_min"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grams_max"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="s2"&gt;"weight_basis"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"carbs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fiber"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"protein"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="s2"&gt;"kcal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gi_confidence"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"grams"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"grams_min"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"grams_max"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"weight_basis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"gi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"gi_confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;&lt;code&gt;provider.require_parameters: true&lt;/code&gt; closes the gap the schema leaves open: it makes OpenRouter route only to endpoints that actually support &lt;code&gt;json_schema&lt;/code&gt;. Without it, your request can land on a provider that silently ignores the schema and returns prose, and you'll waste an evening blaming the model for a routing problem.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;reasoning.effort: "low"&lt;/code&gt; is the setting I'd check first on any cost surprise. Gemini 3.x and GPT-5.x burn hidden reasoning tokens that bill as output, and identifying rice doesn't need chain-of-thought (my post on &lt;a href="https://www.danilchenko.dev/posts/efficient-llm-reasoning/" rel="noopener noreferrer"&gt;efficient LLM reasoning&lt;/a&gt; covers why reasoning-token budgets got out of hand in the first place). Left at default, this setting alone can double the scan price.&lt;/p&gt;

&lt;p&gt;Food photos are health data, so &lt;code&gt;provider.data_collection: "deny"&lt;/code&gt; opts them out of provider-side training.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;usage.include: true&lt;/code&gt; asks OpenRouter to attach the request's exact cost to the response (newer API revisions include the usage block by default). Every scan's cost is written to the database, and the server has a daily budget kill switch sitting on top of those numbers. When something goes wrong (a retry loop, a pricing change upstream) the stop-loss trips before the invoice does.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;user&lt;/code&gt; carries a stable pseudonymous ID so OpenRouter's abuse detection bans an abusive account rather than my whole API key.&lt;/p&gt;

&lt;p&gt;On the client side, photos get downsampled to 1024 px via ImageIO before upload. They land at 100–250 KB of JPEG. I tested bigger inputs and they bought nothing: gram accuracy is limited by scene scale ambiguity, and more pixels don't resolve that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt: scale first, honesty second
&lt;/h2&gt;

&lt;p&gt;The first version of my prompt read like everyone's first food prompt ("identify the foods in this photo and estimate nutrition") and produced confident nonsense. The rewrite starts from the observation the A/B forced on me. The dominant error is portion weight. Weight is unguessable without scene scale. So the system prompt now opens by assigning the model a job and a method, in that order. Verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are a clinical nutrition analyst helping a person with type 1 diabetes count carbohydrates. Analyze the food photo and identify every distinct edible item (dishes, single products, packaged foods).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then the scale hierarchy, the part that did the most work:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Work scale-first:&lt;br&gt;
1) Anchor the physical scale. If the user message includes a depth-camera width measurement, it is the primary reference. Otherwise infer scale from objects of known size: dinner plate 24-26 cm, salad/dessert plate 20 cm, bowl 15-18 cm, fork/knife 19-21 cm, tablespoon 20 cm, glass 7-8 cm wide, credit card 8.6 cm, adult palm ~9 cm wide.&lt;br&gt;
2) From that anchor estimate each item's covered area and layer height, then weight = volume x typical density of the cooked food.&lt;br&gt;
3) For countable pieces (eggs, bread slices, syrniki, cookies, dumplings, sushi) prefer count x typical unit weight (egg ~55 g, bread slice 30-40 g, pelmeni ~12 g, syrnik ~60 g) over volume reasoning.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On devices with LiDAR or stereo depth, the app measures the physical width of the frame at the food's distance and passes it in. Everything else in the hierarchy is a fallback for when hardware can't help. And rule 3 exists because counting beats volume math. "2 eggs" lands within a few grams; "a pile of scrambled egg" doesn't.&lt;/p&gt;

&lt;p&gt;Two fields in the schema exist purely to keep the model honest. &lt;code&gt;weight_basis&lt;/code&gt; forces it to name the anchor it actually used: "plate 26 cm, rice layer 2 cm". That disciplines the estimate and makes failures debuggable, because you can see exactly where the reasoning slipped. And instead of one confident number, the model returns a &lt;code&gt;grams_min&lt;/code&gt;/&lt;code&gt;grams_max&lt;/code&gt; bracket with instructions to earn its width:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keep it tight where the weight is nearly certain (label or package weight, countable pieces: 2 eggs, 3 slices) and widen it where it is not (a mound of rice seen from above, an opaque bowl, a sauce, a partly hidden portion, no scale anchor in frame). ... Do not default to a fixed percentage and do not collapse the bracket to hide uncertainty — an interval that turns out to contain the true weight is worth more to this user than a confident single number.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rest of the prompt is domain policy, compressed. Effort goes where errors hurt: weight accuracy is prioritized for carb-dense items (bread, rice, pasta, sweets), while butter and greens get rough estimates by design. Nutrition comes back per 100 g in the as-served state (cooked pasta, not dry), with &lt;code&gt;carbs&lt;/code&gt; meaning net digestible carbs and fiber in its own field. GI is nullable with an explicit ban on inventing numbers, plus a &lt;code&gt;gi_confidence&lt;/code&gt; grade. If a nutrition label is visible in the frame, transcription beats vision, with fiber subtracted from carbs for US-style labels. And the model never gives insulin dosing advice; that line is not negotiable in a diabetes app.&lt;/p&gt;

&lt;p&gt;The system prompt sets the method; a dynamically assembled user message carries each scan's variables. Four moving parts, composed per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Item names and notes language: German.
The photo is approximately 31 cm wide at the distance of the food.
User hint: this is buckwheat, not rice.
Analyze this photo.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Language first (without it, a German grocery shelf comes back with English item names), then the depth measurement when hardware provides one, then a free-text hint that the prompt instructs the model to trust over its own eyes. When there's no photo at all, the same pipeline accepts a text description of the meal ("two syrniki and a bowl of soup") and estimates typical portions from words alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation catches what the schema can't
&lt;/h2&gt;

&lt;p&gt;JSON Schema only guarantees the shape of the response; a perfectly well-formed answer can still claim 150 g of carbs per 100 g of product. The Go server runs its own validator after unmarshal: carbs ≤ 100 g/100 g, macro sum ≤ 105 g/100 g, GI within [0, 110], and calories cross-checked against the Atwater formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;atwater&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Carbs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Protein&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;9&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fat&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fiber&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kcal&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kcal&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;atwater&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;atwater&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0.25&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kcal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;atwater&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// silently recompute — don't fail the scan&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;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GramsMin&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GramsMax&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GramsMin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GramsMax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GramsMax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GramsMin&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The validator repairs instead of rejecting wherever it can. An inverted weight bracket gets swapped. A &lt;code&gt;grams&lt;/code&gt; value outside its own bracket gets clamped. A meaningless bracket narrower than a gram gets zeroed out. The user paid for this scan with a real LLM call, so it never fails over a decorative field.&lt;/p&gt;

&lt;p&gt;Violations are tiered by severity. Hard nonsense like carbs at 150 g/100 g triggers one retry with feedback: the model receives &lt;code&gt;Your previous answer was invalid: &amp;lt;list of violations&amp;gt;. Return corrected JSON&lt;/code&gt; and a second chance. In practice one feedback round is enough: a paid scan almost never dies on a bad first answer. Cosmetic issues, like a word where the emoji field should be, just get cleaned up server-side without spending tokens on them.&lt;/p&gt;

&lt;p&gt;There's plumbing around the edges that earns its keep, too. Client retries are idempotent via an &lt;code&gt;X-Scan-ID&lt;/code&gt; UUID that lives for the scan's whole lifecycle, so a network error after the paid LLM call can't bill twice. Local development runs on &lt;code&gt;MOCK_LLM=1&lt;/code&gt;, a deterministic mock that mirrors the real model's heuristics, so tests and UI iterations burn zero tokens. This kind of contract-first setup will be familiar if you've done &lt;a href="https://www.danilchenko.dev/posts/markitdown-vs-docling-vs-marker/" rel="noopener noreferrer"&gt;structured document extraction&lt;/a&gt;; the failure modes rhyme even when the domain doesn't. And the broader discipline of packing exactly the right variables into each request — nothing more — is the same instinct I described in &lt;a href="https://www.danilchenko.dev/posts/context-engineering/" rel="noopener noreferrer"&gt;context engineering&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which LLM is best for food recognition?
&lt;/h3&gt;

&lt;p&gt;On DiningBench (3,021 dishes, 29 models), Gemini-3-Pro leads nutrition estimation with 24.45% MAPE versus 32.17% for GPT-5. In my production A/B, though, &lt;code&gt;gemini-3.6-flash&lt;/code&gt; matched the pro model within the noise of gram estimates at 58% lower cost — for this task, the flash tier is the sweet spot.&lt;/p&gt;

&lt;h3&gt;
  
  
  How accurate are LLMs at estimating food weight from a photo?
&lt;/h3&gt;

&lt;p&gt;Portion weight is where the pipeline is weakest: every model I tested lands 25–35% off. A depth-camera scale reference, known-size objects in frame, and counting pieces instead of estimating volume all narrow the error, but an editable weight in the UI is still the most reliable fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you get strict JSON from Gemini through OpenRouter?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;response_format&lt;/code&gt; with &lt;code&gt;json_schema&lt;/code&gt; and &lt;code&gt;strict: true&lt;/code&gt;, put every field in &lt;code&gt;required&lt;/code&gt;, set &lt;code&gt;additionalProperties: false&lt;/code&gt; on each object, and add &lt;code&gt;provider.require_parameters: true&lt;/code&gt; so the request only routes to endpoints that honor schemas. The response then parses with a plain &lt;code&gt;json.Unmarshal&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can an LLM read nutrition labels from a photo?
&lt;/h3&gt;

&lt;p&gt;Yes, and it should — my prompt makes a visible label override visual guessing entirely, the same way transcription beats inference in &lt;a href="https://www.danilchenko.dev/posts/mistral-ocr-4-tutorial/" rel="noopener noreferrer"&gt;document OCR pipelines&lt;/a&gt;. The one trap is US-style labels counting total carbs: the prompt has to spell out subtracting fiber to get net carbs, or packaged foods come back inflated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2604.10425" rel="noopener noreferrer"&gt;DiningBench — arXiv:2604.10425&lt;/a&gt; — the 29-model food recognition benchmark that picked my starting model&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apps.apple.com/cy/app/soba-carb-glycemic-index/id6792708725" rel="noopener noreferrer"&gt;soba on the App Store&lt;/a&gt; — the app all of this ships in&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openrouter.ai/docs/features/structured-outputs" rel="noopener noreferrer"&gt;OpenRouter structured outputs docs&lt;/a&gt; — &lt;code&gt;json_schema&lt;/code&gt;, strict mode, and &lt;code&gt;require_parameters&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://doi.org/10.3390/nu18122017" rel="noopener noreferrer"&gt;Prompt engineering and model selection for LLM-based nutritional estimation — Nutrients&lt;/a&gt; — reports prompt design moving accuracy on par with model choice&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC6246963/" rel="noopener noreferrer"&gt;Calorie estimation from photos, crowdsourcing study — JMIR&lt;/a&gt; — portion-size error predates LLMs by a decade&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;The recognition stack in soba got better three times: when a benchmark replaced my gut feeling, when strict schemas replaced "please return JSON", and when the prompt started arguing about centimeters instead of cuisine. It never got noticeably better from a model upgrade alone. If you're building anything that turns photos into numbers a person will act on, spend your week on scale anchors, honest intervals, and repair-first validation. The model is the least interesting part of the pipeline, which is exactly how you want it.&lt;/p&gt;

</description>
      <category>foodrecognition</category>
      <category>gemini</category>
      <category>openrouter</category>
      <category>structuredoutput</category>
    </item>
    <item>
      <title>Faster Than Dijkstra: The New Shortest-Path Algorithm, Explained</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Thu, 13 Aug 2026 08:45:38 +0000</pubDate>
      <link>https://dev.to/dmaxdev/faster-than-dijkstra-the-new-shortest-path-algorithm-explained-4ok0</link>
      <guid>https://dev.to/dmaxdev/faster-than-dijkstra-the-new-shortest-path-algorithm-explained-4ok0</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;For 65 years, Dijkstra's algorithm was the fastest known way to compute single-source shortest paths, and most of us assumed it was optimal. In 2025, a team led by Ran Duan at Tsinghua University proved it isn't: their deterministic algorithm runs in O(m log^(2/3) n) time and beats Dijkstra on sparse directed graphs. A February 2026 follow-up pushed the bound even lower. The catch, and it's a big one: on real hardware, plain Dijkstra is still reported to be 3–4× faster, and it'll stay that way for any graph you'll ever load into memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The barrier that stood since 1959
&lt;/h2&gt;

&lt;p&gt;Edsger Dijkstra published his shortest-path algorithm in 1959. You give it a weighted graph and a starting vertex; it hands back the shortest distance to every other vertex. It has been in every algorithms textbook, every routing stack, and every whiteboard interview since.&lt;/p&gt;

&lt;p&gt;The version we actually reason about today pairs Dijkstra's greedy strategy with a Fibonacci heap, a result from Fredman and Tarjan in 1984 that gets you O(m + n log n) time for &lt;code&gt;m&lt;/code&gt; edges and &lt;code&gt;n&lt;/code&gt; vertices. On a dense graph, where &lt;code&gt;m&lt;/code&gt; is close to n², the &lt;code&gt;m&lt;/code&gt; term dominates and the algorithm is basically linear in the input. On a &lt;strong&gt;sparse&lt;/strong&gt; graph, where each vertex has a handful of edges and &lt;code&gt;m&lt;/code&gt; is roughly proportional to &lt;code&gt;n&lt;/code&gt;, the &lt;code&gt;n log n&lt;/code&gt; piece takes over. That log-n factor is the tax, and for decades it looked like a tax you had to pay.&lt;/p&gt;

&lt;p&gt;Here is why the tax exists. Dijkstra settles vertices in strictly increasing order of distance: closest first, then next-closest, and so on. To always pull out the current minimum, it keeps its frontier in a priority queue and pays O(log n) per extraction. Do that &lt;code&gt;n&lt;/code&gt; times and you have effectively &lt;strong&gt;sorted all &lt;code&gt;n&lt;/code&gt; vertices by distance&lt;/strong&gt;. Sorting &lt;code&gt;n&lt;/code&gt; comparable things has a known lower bound of Ω(n log n), so as long as your algorithm produces a fully sorted output, you can't dip below it. Researchers called this the &lt;em&gt;sorting barrier&lt;/em&gt;, and the open question was whether shortest paths actually require it, or whether the sorting was just an accident of how Dijkstra happens to work.&lt;/p&gt;

&lt;p&gt;I wanted to feel the barrier before reading how it fell, so I dropped a textbook Dijkstra into a file and timed it. Here is the whole thing:&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;import&lt;/span&gt; &lt;span class="n"&gt;heapq&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dijkstra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;inf&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;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;source&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="n"&gt;pq&lt;/span&gt; &lt;span class="o"&gt;=&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;source&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;          &lt;span class="c1"&gt;# the heap IS the sorting barrier
&lt;/span&gt;    &lt;span class="n"&gt;settled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heappop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pq&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;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;settled&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="n"&gt;u&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;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&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;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
                &lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heappush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;

&lt;span class="n"&gt;g&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;A&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;C&lt;/span&gt;&lt;span class="sh"&gt;'&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;D&lt;/span&gt;&lt;span class="sh"&gt;'&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;C&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;D&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;D&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;E&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;E&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;distances from A:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;dijkstra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it prints the shortest distance from &lt;code&gt;A&lt;/code&gt; to everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;distances from A: {'A': 0, 'B': 3, 'C': 1, 'D': 4, 'E': 7}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The direct edge &lt;code&gt;A→B&lt;/code&gt; costs 4, but the detour &lt;code&gt;A→C→B&lt;/code&gt; costs 1 + 2 = 3, so the algorithm picks 3. Standard stuff. Then I generated a random sparse graph (average degree 3, so &lt;code&gt;m ≈ 3n&lt;/code&gt;) and let the same function loose on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n=  100,000  m=  299,999  dijkstra:  1.11s
n=1,000,000  m=2,999,999  dijkstra: 16.92s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten times the vertices, roughly fifteen times the wall-clock. That super-linear creep is the &lt;code&gt;log n&lt;/code&gt; factor showing up in a real profiler (pure Python inflates the constants, so read these as shape rather than a benchmark). Every one of those seconds is the heap keeping the frontier in perfect sorted order. The Duan result asks a sharp question: what if it didn't have to?&lt;/p&gt;

&lt;h2&gt;
  
  
  What Duan and his co-authors actually did
&lt;/h2&gt;

&lt;p&gt;The paper is &lt;a href="https://arxiv.org/abs/2504.17033" rel="noopener noreferrer"&gt;&lt;em&gt;Breaking the Sorting Barrier for Directed Single-Source Shortest Paths&lt;/em&gt;&lt;/a&gt; by Ran Duan, Jiayi Mao, Xiao Mao, Xinkai Shu, and Longhui Yin. It won a Best Paper Award at STOC 2025, the top theory conference, held in Prague that June. The headline result: a deterministic O(m log^(2/3) n)-time algorithm for single-source shortest paths on directed graphs with non-negative real weights, in the comparison-addition model. On sparse graphs that's asymptotically below O(m + n log n), so it's the first algorithm to prove Dijkstra isn't optimal for this problem.&lt;/p&gt;

&lt;p&gt;The trick is to stop insisting on a fully sorted frontier. You don't need to know the exact order in which the next thousand vertices settle. You only need to keep finding correct shortest distances. So instead of one global priority queue, the algorithm runs a recursive routine the authors call &lt;strong&gt;BMSSP&lt;/strong&gt;, for Bounded Multi-Source Shortest Path. Each call is handed a distance bound &lt;code&gt;B&lt;/code&gt; and told: find every shortest path shorter than &lt;code&gt;B&lt;/code&gt;, starting from a set of sources rather than a single one. It splits that job into smaller bounded sub-problems and recurses.&lt;/p&gt;

&lt;p&gt;One blogger summed up the shape of it in a line I liked: &lt;em&gt;"Dijkstra is to BMSSP as binary-tree sort is to merge sort."&lt;/em&gt; Dijkstra grows one ordered structure incrementally; BMSSP divides, conquers, and merges bounded pieces, and never pays for a total order it won't use.&lt;/p&gt;

&lt;p&gt;The move that keeps the whole thing cheap is &lt;strong&gt;frontier reduction&lt;/strong&gt;. Before recursing, a step called &lt;code&gt;FindPivots&lt;/code&gt; runs a few rounds of Bellman-Ford-style relaxation out from the current frontier, just a handful of layers rather than the full graph. Most vertices it touches turn out to hang off a small number of "pivot" vertices that sit at the root of large shortest-path trees. The algorithm keeps only those pivots and drops the rest from the active set, shrinking the frontier by a factor of roughly &lt;code&gt;k&lt;/code&gt; each time. A smaller frontier means fewer things to track, and fewer things to sort. Sorting was the whole tax. Stack about log^(2/3) n of these recursion levels on top of each other and the log-n cost collapses to log^(2/3) n.&lt;/p&gt;

&lt;p&gt;That Bellman-Ford cameo is the satisfying part. Bellman-Ford is the &lt;em&gt;other&lt;/em&gt; shortest-path algorithm everyone learns: slower, brute-force, relaxing every edge over and over. On its own it loses to Dijkstra badly. Bolted on as a frontier-pruning pre-pass, it's exactly the tool that lets you skip the sorting Dijkstra can't. The winning design fuses the two classics rather than replacing either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2026 sequel went further
&lt;/h2&gt;

&lt;p&gt;Barriers, once cracked, tend to keep cracking. In February 2026 an overlapping group (Ran Duan, Xiao Mao, Xinkai Shu, and Longhui Yin) posted &lt;a href="https://arxiv.org/abs/2602.07868" rel="noopener noreferrer"&gt;&lt;em&gt;A Faster Directed Single-Source Shortest Path Algorithm&lt;/em&gt;&lt;/a&gt;. Its full running time is O(m√(log n) + √(mn log n log log n)), which on sparse graphs simplifies to O(m √(log n · log log n)) and drops the exponent on the log from 2/3 toward 1/2. Same problem, same deterministic setting, tighter analysis and a refined recursion. Less than a year after the first paper, the frontier had already moved.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;Result year&lt;/th&gt;
&lt;th&gt;Time on sparse directed graphs&lt;/th&gt;
&lt;th&gt;Beats Dijkstra?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dijkstra + Fibonacci heap&lt;/td&gt;
&lt;td&gt;1984 (Fredman–Tarjan)&lt;/td&gt;
&lt;td&gt;O(m + n log n)&lt;/td&gt;
&lt;td&gt;— (the baseline)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duan, Mao, Mao, Shu, Yin (BMSSP)&lt;/td&gt;
&lt;td&gt;2025&lt;/td&gt;
&lt;td&gt;O(m log^(2/3) n)&lt;/td&gt;
&lt;td&gt;Yes, asymptotically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duan, Mao, Shu, Yin&lt;/td&gt;
&lt;td&gt;2026&lt;/td&gt;
&lt;td&gt;O(m √(log n · log log n))&lt;/td&gt;
&lt;td&gt;Yes, further still&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither result is a small tweak. Both are genuine progress on a problem that had not moved in decades, and the theory community treats them as landmark work. If you study algorithms, this is the most exciting thing to happen to shortest paths in a long time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the speedup disappears in practice
&lt;/h2&gt;

&lt;p&gt;Now the honest column of the ledger. "Faster than Dijkstra" is true in the asymptotic, worst-case, big-O sense, and that sense hides enormous constant factors.&lt;/p&gt;

&lt;p&gt;A November 2025 paper by Lucas Castro, Thailsson Clementino, and Rosiane de Freitas, &lt;a href="https://arxiv.org/abs/2511.03007" rel="noopener noreferrer"&gt;&lt;em&gt;Implementation and Brief Experimental Analysis of the Duan et al. (2025) Algorithm&lt;/em&gt;&lt;/a&gt;, did the unglamorous work of actually coding it up and racing it against Dijkstra. They tested sparse random graphs, grids, and U.S. road networks up to 10 million vertices. Their finding is blunt: &lt;strong&gt;Dijkstra's algorithm stays 3 to 4 times faster in every scenario they tried.&lt;/strong&gt; The recursion, the pivot bookkeeping, and the Bellman-Ford passes carry so much overhead that the asymptotic win never gets a chance to pay off. By their estimate, a graph would need to exceed 10^67 vertices before the worst-case bound overtakes Dijkstra in practice. There are about 10^80 atoms in the observable universe. You're never going to hit that input size.&lt;/p&gt;

&lt;p&gt;None of this diminishes the theory. A worst-case upper bound is a statement about what is &lt;em&gt;possible&lt;/em&gt;, and lowering it redraws what the next decade of research takes for granted. Practical speedups have a long history of arriving years after the asymptotic breakthrough that made them thinkable. Someone finds the idea that removes the constants, and a "useless" bound becomes a real library. The 2025 result opened that door. Whether anyone walks a fast version through it is a separate, open problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes for my own code
&lt;/h2&gt;

&lt;p&gt;Concretely, for the work I actually ship: nothing, and I think that is the right takeaway rather than a disappointing one. When I need shortest paths, I still reach for Dijkstra with a binary heap, or A* when I have a decent distance heuristic, or contraction hierarchies when I'm precomputing a road network and can afford the setup. Those are still the fastest tools for graphs that fit on a machine.&lt;/p&gt;

&lt;p&gt;What did change is how I read a complexity result. Sitting next to my own timing numbers, a million-node graph chewing up 16.9 seconds with most of it inside the heap, the BMSSP paper is a clean demonstration that a bound you've quietly accepted as a law can just be a habit. The sorting barrier felt like a law of nature. It turned out to be a quirk of one particular algorithm, and the problem itself never demanded it. Worth keeping in mind the next time some O(n log n) in your own stack feels permanent. It might only be waiting for the right recursion.&lt;/p&gt;

&lt;p&gt;If you want to reproduce the feel of it, the Dijkstra snippet above runs as-is on any Python 3, and the two papers below are both readable if you're comfortable with graph proofs. Start with the 2025 one; the introduction alone explains the sorting barrier better than most textbooks. For a broader tour of how algorithmic lower bounds have been falling lately, I wrote up the &lt;a href="https://www.danilchenko.dev/posts/2026-03-30-four-color-theorem-near-linear-time-algorithm/" rel="noopener noreferrer"&gt;four color theorem dropping to O(n log n)&lt;/a&gt;, another 2026 result where a barrier everyone treated as settled quietly gave way. And if your interest in "doing less work" is more about models than graphs, the same instinct drives &lt;a href="https://www.danilchenko.dev/posts/efficient-llm-reasoning/" rel="noopener noreferrer"&gt;efficient LLM reasoning&lt;/a&gt; and &lt;a href="https://www.danilchenko.dev/posts/sparse-attention-explained/" rel="noopener noreferrer"&gt;sparse attention&lt;/a&gt;, where the trick is likewise skipping computation you were told you needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is there an algorithm faster than Dijkstra?
&lt;/h3&gt;

&lt;p&gt;Yes, as of 2025. The Duan et al. algorithm runs in O(m log^(2/3) n) time on directed graphs with non-negative weights, which is asymptotically faster than Dijkstra's O(m + n log n) on sparse graphs. A 2026 follow-up is faster still. In everyday practice, though, well-tuned Dijkstra remains 3–4× quicker on any real-world graph, because the new algorithm's constant factors are huge.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the new shortest path algorithm called?
&lt;/h3&gt;

&lt;p&gt;The paper doesn't brand it with a catchy name, but its core recursive routine is &lt;strong&gt;BMSSP&lt;/strong&gt;, short for Bounded Multi-Source Shortest Path. People usually refer to it as "the Duan et al. algorithm" or "the algorithm that broke the sorting barrier."&lt;/p&gt;

&lt;h3&gt;
  
  
  How does the new shortest-path algorithm work?
&lt;/h3&gt;

&lt;p&gt;It avoids fully sorting the frontier. Dijkstra settles vertices in exact distance order using a priority queue, which forces an Ω(n log n) sorting cost. BMSSP instead solves many &lt;em&gt;bounded&lt;/em&gt; shortest-path sub-problems recursively, and before each recursion a &lt;code&gt;FindPivots&lt;/code&gt; step runs a few Bellman-Ford relaxation rounds to shrink the frontier down to a small set of "pivot" vertices. Fewer vertices to track means less sorting, and the log-n factor drops to log^(2/3) n.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who discovered the new shortest path algorithm?
&lt;/h3&gt;

&lt;p&gt;A team led by Ran Duan at Tsinghua University, with co-authors Jiayi Mao, Xiao Mao, Xinkai Shu, and Longhui Yin. They presented it at STOC 2025 in Prague, where it won a Best Paper Award.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I replace Dijkstra with it in production?
&lt;/h3&gt;

&lt;p&gt;No. For graphs that fit in memory, which is essentially all of them, Dijkstra with a good heap, A*, or contraction hierarchies will beat it comfortably. The new algorithm is a statement about worst-case complexity, and it won't drop into your stack as a speedup. Treat it as proof the barrier is breakable rather than as your next routing library.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2504.17033" rel="noopener noreferrer"&gt;Breaking the Sorting Barrier for Directed Single-Source Shortest Paths — arXiv:2504.17033&lt;/a&gt; — the original STOC 2025 Best Paper this post summarizes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dl.acm.org/doi/10.1145/3717823.3718179" rel="noopener noreferrer"&gt;Breaking the Sorting Barrier — ACM STOC 2025 proceedings&lt;/a&gt; — the peer-reviewed conference version&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2602.07868" rel="noopener noreferrer"&gt;A Faster Directed Single-Source Shortest Path Algorithm — arXiv:2602.07868&lt;/a&gt; — the February 2026 follow-up improving the bound&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2511.03007" rel="noopener noreferrer"&gt;Implementation and Brief Experimental Analysis of the Duan et al. (2025) Algorithm — arXiv:2511.03007&lt;/a&gt; — the benchmark study finding Dijkstra 3–4× faster in practice&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Dijkstra's 65-year reign as the provably fastest way to find shortest paths is over, at least on paper. The Duan team showed the sorting barrier was never a law of the problem, only a feature of one very good algorithm, and they did it with a clever hybrid of Dijkstra and the humble Bellman-Ford. That's a beautiful result and it deserves the award it got. It also won't touch your production routing code for years, if ever, and the researchers who benchmarked it are the first to say so. Keep shipping Dijkstra. Just know, now, that it was never the finish line.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>dijkstra</category>
      <category>shortestpath</category>
      <category>graphtheory</category>
    </item>
    <item>
      <title>Go CLI Frameworks in 2026: Cobra vs urfave/cli vs Bubble Tea</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Mon, 27 Jul 2026 08:44:25 +0000</pubDate>
      <link>https://dev.to/dmaxdev/go-cli-frameworks-in-2026-cobra-vs-urfavecli-vs-bubble-tea-529c</link>
      <guid>https://dev.to/dmaxdev/go-cli-frameworks-in-2026-cobra-vs-urfavecli-vs-bubble-tea-529c</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;For subcommands, flags, and shell completions, the shape of &lt;code&gt;kubectl&lt;/code&gt; or &lt;code&gt;gh&lt;/code&gt;, reach for &lt;strong&gt;Cobra&lt;/strong&gt;. For a single command with a handful of flags and the least ceremony possible, &lt;strong&gt;urfave/cli v3&lt;/strong&gt; gets you there with half the boilerplate. &lt;strong&gt;Bubble Tea&lt;/strong&gt; plays a different game: it draws interactive terminal UIs (menus, spinners, forms), and you usually bolt it onto a Cobra command rather than pick between them. I built the same deploy tool three times to figure out where the lines actually fall, and the verdict depends more on which problem you have than which library is objectively best.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I rebuilt the same CLI three times
&lt;/h2&gt;

&lt;p&gt;Two weeks ago I got tired of guessing. I maintain a small internal deploy tool at work, the kind of thing that reads a config, asks which environment you mean, and shells out to a few commands. (I walked through building a single-purpose CLI from scratch in Python in an &lt;a href="https://www.danilchenko.dev/posts/uv-python-tutorial/" rel="noopener noreferrer"&gt;earlier uv tutorial&lt;/a&gt;; this is the Go counterpart.) It started as a 40-line &lt;code&gt;flag&lt;/code&gt; package script and had grown warts. So I rewrote it three times in one afternoon: once with Cobra, once with urfave/cli v3, once as a Bubble Tea TUI. Same behavior, three implementations, so I could compare boilerplate, binary size, and how each one felt under my fingers instead of trusting a table someone else made.&lt;/p&gt;

&lt;p&gt;That exercise settled a few things I had been repeating without checking. urfave/cli v3 is a genuinely different library from the v2 most tutorials still show. Bubble Tea v2 quietly broke its own API in ways that will bite you if you copy an old snippet. And Cobra, the one everyone calls heavyweight, wrote itself faster than I expected because &lt;code&gt;cobra-cli&lt;/code&gt; scaffolds the whole tree. This post is what I learned, with the code I actually ran.&lt;/p&gt;

&lt;p&gt;If you want the wider Go tooling context, I keep a running &lt;a href="https://www.danilchenko.dev/posts/modern-go-2026/" rel="noopener noreferrer"&gt;Modern Go in 2026 guide&lt;/a&gt; that this piece slots into.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split: parsers versus painters
&lt;/h2&gt;

&lt;p&gt;The single most common mistake I see in "best Go CLI library" threads is treating these three as interchangeable, when two of them parse arguments and one of them paints screens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cobra&lt;/strong&gt; and &lt;strong&gt;urfave/cli&lt;/strong&gt; both answer the same question: given &lt;code&gt;mytool deploy --env prod service-a&lt;/code&gt;, how do I route to the right code with the right values? They give you commands, flags, arguments, help text, and completions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bubble Tea&lt;/strong&gt; answers a different question: how do I draw a live, keyboard-driven interface in the terminal (a selectable list, a progress bar, a text form) that redraws as the user types? It owns the screen rather than the argument vector. When people say "Cobra vs Bubble Tea," what they usually want is "Cobra for the command tree, Bubble Tea for the interactive bits," and the two compose cleanly.&lt;/p&gt;

&lt;p&gt;Go's single static binary and fast startup are a big part of why so many CLIs get written in it, the same traits that keep it &lt;a href="https://www.danilchenko.dev/posts/rust-vs-go/" rel="noopener noreferrer"&gt;in the fight with Rust for systems work&lt;/a&gt;. For rough popularity: Cobra sits near 44k GitHub stars, urfave/cli near 24k, and Bubble Tea near 44k, reported at the time of writing and moving around, so treat them as signals rather than precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Cobra&lt;/th&gt;
&lt;th&gt;urfave/cli v3&lt;/th&gt;
&lt;th&gt;Bubble Tea&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subcommand trees&lt;/td&gt;
&lt;td&gt;Yes, deep nesting&lt;/td&gt;
&lt;td&gt;Yes, flatter feel&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flag parsing&lt;/td&gt;
&lt;td&gt;pflag (POSIX)&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto help / usage&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shell completions&lt;/td&gt;
&lt;td&gt;bash/zsh/fish/pwsh&lt;/td&gt;
&lt;td&gt;bash/zsh/fish&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config integration&lt;/td&gt;
&lt;td&gt;Viper (same author)&lt;/td&gt;
&lt;td&gt;Manual / altsrc&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive UI&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (core purpose)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boilerplate for hello-world&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Higher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaffolding tool&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cobra-cli&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latest line at writing&lt;/td&gt;
&lt;td&gt;v1.10.x&lt;/td&gt;
&lt;td&gt;v3.10.x&lt;/td&gt;
&lt;td&gt;v2.x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is easy to skim past. Both urfave/cli and Bubble Tea shipped major versions that changed their APIs, and a lot of the tutorials Google surfaces are still on the old ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cobra: the one that scales
&lt;/h2&gt;

&lt;p&gt;Cobra is what the big tools use. &lt;code&gt;kubectl&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;hugo&lt;/code&gt;, and a long list of infrastructure binaries route their commands through &lt;a href="https://github.com/spf13/cobra" rel="noopener noreferrer"&gt;Cobra&lt;/a&gt;. The reason is nested subcommands: &lt;code&gt;git remote add origin ...&lt;/code&gt; is three levels deep, and Cobra models that tree natively with per-command flags, help, and completions generated for free.&lt;/p&gt;

&lt;p&gt;Here is the greeting command from my deploy tool, cut down to what runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/spf13/cobra"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;verbose&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;

    &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cobra&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="n"&gt;Use&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;"greet [name]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Short&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Greet someone by name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Args&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;cobra&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExactArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&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;cobra&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="n"&gt;args&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verbose&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"verbose mode on"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hello, %s!&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flags&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BoolVarP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;verbose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"verbose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"enable verbose output"&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Execute&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it and the plumbing is already there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ go run . greet Ada --verbose
verbose mode on
Hello, Ada!

$ go run . --help
Usage:
  greet [name] [flags]

Flags:
  -h, --help      help for greet
  -v, --verbose   enable verbose output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--help&lt;/code&gt; output, the flag validation, the arg count check (&lt;code&gt;cobra.ExactArgs(1)&lt;/code&gt;): none of that is code I wrote. For a single command it feels like a lot of struct scaffolding. The payoff shows up at the third or fourth subcommand, when you add &lt;code&gt;deploy&lt;/code&gt;, &lt;code&gt;rollback&lt;/code&gt;, and &lt;code&gt;status&lt;/code&gt; as children of the root and each one carries its own flags without you rewiring anything.&lt;/p&gt;

&lt;p&gt;The other half of the Cobra story is &lt;strong&gt;Viper&lt;/strong&gt;, from the same author. Viper layers config files, enviroment variables, and flags into one precedence chain, so &lt;code&gt;--env&lt;/code&gt; on the command line overrides &lt;code&gt;DEPLOY_ENV&lt;/code&gt; overrides &lt;code&gt;config.yaml&lt;/code&gt;. If your tool has real configuration, that pairing is the reason to stay on Cobra. I lean on it whenever a tool needs to read a config file, which is most of them.&lt;/p&gt;

&lt;p&gt;Where Cobra frustrates me: the empty-&lt;code&gt;Run&lt;/code&gt;-function ritual for parent commands, and the fact that you scaffold a lot before the first useful line runs. The &lt;code&gt;cobra-cli init&lt;/code&gt; command papers over that by writing the whole tree for you, and honestly it's the fastest way to start a serious multi-command tool in Go today.&lt;/p&gt;

&lt;h2&gt;
  
  
  urfave/cli v3: less ceremony, same job
&lt;/h2&gt;

&lt;p&gt;urfave/cli is the library I reach for when the tool is one command with a few flags and I don't want to think about a command tree. v3 is the current line, and it's worth being explicit that &lt;a href="https://github.com/urfave/cli/releases" rel="noopener noreferrer"&gt;it broke from v2&lt;/a&gt;: the action signature now takes a &lt;code&gt;context.Context&lt;/code&gt; and a &lt;code&gt;*cli.Command&lt;/code&gt;, and you run with &lt;code&gt;cmd.Run(ctx, os.Args)&lt;/code&gt;. Copy a v2 snippet into a v3 project and it won't compile, and the error messages aren't obvious. That single gotcha cost me ten minutes.&lt;/p&gt;

&lt;p&gt;The same greeting, in v3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/urfave/cli/v3"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cli&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="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;"greet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Usage&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"greet someone by name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Flags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;cli&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Flag&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cli&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BoolFlag&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"verbose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Aliases&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&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;cli&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="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&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;Args&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;First&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"name is required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"verbose"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"verbose mode on"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Hello, %s!&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&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;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I like here over Cobra. The action returns an &lt;code&gt;error&lt;/code&gt;, so failure handling is a normal Go return instead of an &lt;code&gt;os.Exit&lt;/code&gt; buried in a closure. And there's no &lt;code&gt;Args: cobra.ExactArgs(1)&lt;/code&gt; vocabulary to learn. You pull arguments off &lt;code&gt;cmd.Args()&lt;/code&gt; and validate them yourself, which for a small tool is one line and reads plainly.&lt;/p&gt;

&lt;p&gt;The tradeoff is that everything Cobra gives you automatically becomes your job at scale. Nested commands exist in urfave/cli via a &lt;code&gt;Commands&lt;/code&gt; slice, but the ergonomics get thinner the deeper you nest, and there's no &lt;code&gt;cobra-cli&lt;/code&gt; equivalent to scaffold them. My rule of thumb after this exercise: one-to-three commands, urfave/cli; a real tree with shared config, Cobra.&lt;/p&gt;

&lt;p&gt;For a worked example of a single-purpose Go tool built end to end, I walked through one in the &lt;a href="https://www.danilchenko.dev/posts/go-ai-agent/" rel="noopener noreferrer"&gt;Go AI agent tutorial&lt;/a&gt;, and the argument-handling shape there is exactly the urfave/cli sweet spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bubble Tea: when a flag isn't enough
&lt;/h2&gt;

&lt;p&gt;Some tools shouldn't answer a question with a flag. "Which environment?" is nicer as an arrow-key menu than as &lt;code&gt;--env prod&lt;/code&gt;, especially for a tool humans run interactively. That kind of prompt is what Bubble Tea is for. It implements the Elm architecture: a &lt;code&gt;Model&lt;/code&gt; holding state, an &lt;code&gt;Update&lt;/code&gt; that folds messages into new state, and a &lt;code&gt;View&lt;/code&gt; that renders state to a string. It redraws on every keystroke.&lt;/p&gt;

&lt;p&gt;Here is the environment picker from my deploy tool. This targets the classic Bubble Tea API (see the version note right after; v2 changes some of this):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;

    &lt;span class="n"&gt;tea&lt;/span&gt; &lt;span class="s"&gt;"github.com/charmbracelet/bubbletea"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="k"&gt;struct&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="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt;   &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cmd&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeyMsg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"up"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"k"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"down"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"j"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"enter"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quit&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ctrl+c"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quit&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;View&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"Pick an environment:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;gt;"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;(↑/↓ move · enter select · q quit)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"staging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
    &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewProgram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Deploying to %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it gives you a live menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pick an environment:

  dev
&amp;gt; staging
  production

(↑/↓ move · enter select · q quit)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bubble Tea rarely travels alone. &lt;strong&gt;Lip Gloss&lt;/strong&gt; styles the strings (colors, borders, padding), &lt;strong&gt;Bubbles&lt;/strong&gt; ships pre-built components (text inputs, spinners, tables, paginators), and &lt;strong&gt;Huh&lt;/strong&gt; wraps the whole thing into ready-made forms if you just want a questionnaire. That family is Charm, and it's why so many recent Go TUIs share a look.&lt;/p&gt;

&lt;h3&gt;
  
  
  What changed in Bubble Tea v2
&lt;/h3&gt;

&lt;p&gt;If you're starting fresh in 2026, know that &lt;a href="https://github.com/charmbracelet/bubbletea/releases/tag/v2.0.0" rel="noopener noreferrer"&gt;Bubble Tea v2 is a breaking release&lt;/a&gt;, and copying the snippet above verbatim into a v2 project won't fully work. The changes that bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Key messages now split into &lt;code&gt;tea.KeyPressMsg&lt;/code&gt; and &lt;code&gt;tea.KeyReleaseMsg&lt;/code&gt;. You can still match both with the &lt;code&gt;tea.KeyMsg&lt;/code&gt; interface, but &lt;code&gt;key.Type&lt;/code&gt;/&lt;code&gt;key.Runes&lt;/code&gt; became &lt;code&gt;key.Code&lt;/code&gt;/&lt;code&gt;key.Text&lt;/code&gt;, and modifiers moved into &lt;code&gt;key.Mod&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The big one: &lt;code&gt;View()&lt;/code&gt; no longer returns a plain &lt;code&gt;string&lt;/code&gt;. It returns a &lt;code&gt;tea.View&lt;/code&gt; struct that declares content, cursor, alt-screen mode, colors, and more, which is the change most likely to break an old &lt;code&gt;View() string&lt;/code&gt; method.&lt;/li&gt;
&lt;li&gt;Imports moved to the vanity path &lt;code&gt;charm.land/bubbletea/v2&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental model (model, update, view, messages) is identical. The signatures are what shifted. I kept the example on the v1 API because it's the version the vast majority of existing tutorials and libraries still target, and it runs today. When you upgrade, the migration is mechanical, just tedious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combining Cobra and Bubble Tea
&lt;/h2&gt;

&lt;p&gt;This is the setup I shipped, and it's the answer to most "which one" arguments: use both. Cobra owns the command tree and flags; a Bubble Tea program launches inside a command's &lt;code&gt;Run&lt;/code&gt; when the tool needs interaction. Falling back to a flag when one is supplied keeps it scriptable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;deployCmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cobra&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="n"&gt;Use&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;"deploy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Short&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Deploy a service to an environment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RunE&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&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;cobra&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="n"&gt;args&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&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;Flags&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"env"&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;env&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c"&gt;// No flag given: ask interactively with Bubble Tea.&lt;/span&gt;
            &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"staging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
            &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tea&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewProgram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Deploying to %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&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;&lt;code&gt;deploy --env prod&lt;/code&gt; runs non-interactively in CI; a bare &lt;code&gt;deploy&lt;/code&gt; opens the menu for a human. You get Cobra's completions and help for free, and Bubble Tea only for the part that benefits from it. That split is why I stopped thinking of these as competitors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Multi-command tool, shared config, completions&lt;/td&gt;
&lt;td&gt;Cobra (+ Viper)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One or two commands, minimal deps, quick&lt;/td&gt;
&lt;td&gt;urfave/cli v3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive menu, form, dashboard, or spinner&lt;/td&gt;
&lt;td&gt;Bubble Tea (+ Lip Gloss / Bubbles)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scriptable in CI &lt;em&gt;and&lt;/em&gt; friendly for humans&lt;/td&gt;
&lt;td&gt;Cobra + Bubble Tea together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A throwaway script, one flag&lt;/td&gt;
&lt;td&gt;stdlib &lt;code&gt;flag&lt;/code&gt; (skip all three)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;flag&lt;/code&gt; row isn't a joke. The standard library parses flags fine, and for a 50-line utility that nobody else runs, pulling in a framework is overhead you'll feel in build time and binary size for no benefit. I reach for a framework when the tool grows a second command or a real user other than me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Cobra (+ scaffolding tool)&lt;/span&gt;
go get github.com/spf13/cobra@latest
go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/spf13/cobra-cli@latest
cobra-cli init mytool

&lt;span class="c"&gt;# urfave/cli v3&lt;/span&gt;
go get github.com/urfave/cli/v3@latest

&lt;span class="c"&gt;# Bubble Tea + Charm libraries&lt;/span&gt;
go get github.com/charmbracelet/bubbletea@latest
go get github.com/charmbracelet/lipgloss@latest
go get github.com/charmbracelet/bubbles@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For anything Cobra-based, run &lt;code&gt;cobra-cli init&lt;/code&gt; first and let it write the tree. It's the single biggest time-saver in this whole space. Then add commands with &lt;code&gt;cobra-cli add deploy&lt;/code&gt;. If you're on Bubble Tea, start from an official example in their repo rather than a blog snippet, because the v2 API churn means half the snippets online are subtly stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Cobra or urfave/cli better?
&lt;/h3&gt;

&lt;p&gt;Neither wins in the abstract. Cobra is better for tools with deep subcommand trees and configuration (it powers &lt;code&gt;kubectl&lt;/code&gt; and &lt;code&gt;gh&lt;/code&gt; for a reason). urfave/cli v3 is better for small tools where you want less boilerplate and a normal &lt;code&gt;error&lt;/code&gt;-returning action. Pick by the shape of your command tree rather than by popularity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Bubble Tea a replacement for Cobra?
&lt;/h3&gt;

&lt;p&gt;No. Bubble Tea draws interactive terminal interfaces; Cobra parses commands and flags. They solve different problems and compose well: Cobra for the command structure, Bubble Tea launched inside a command for the interactive parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  What CLI library does the GitHub CLI use?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;gh&lt;/code&gt; is built on Cobra, as are &lt;code&gt;kubectl&lt;/code&gt;, &lt;code&gt;hugo&lt;/code&gt;, and many other large Go tools. The draw is native nested subcommands, generated help, and shell completions across bash, zsh, fish, and PowerShell.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Cobra to use Bubble Tea?
&lt;/h3&gt;

&lt;p&gt;No. Bubble Tea runs standalone as &lt;code&gt;tea.NewProgram(model).Run()&lt;/code&gt;. You only pair it with Cobra when you also need a command tree and flags around the interactive UI. For a single interactive screen, Bubble Tea alone is enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the best Go CLI framework in 2026?
&lt;/h3&gt;

&lt;p&gt;For most non-trivial tools, Cobra, because of subcommands, Viper config, and completions. For small single-purpose tools, urfave/cli v3. For interactive UIs, Bubble Tea. There's no one winner. The right answer depends on whether you're parsing commands, keeping it minimal, or drawing a screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/spf13/cobra" rel="noopener noreferrer"&gt;Cobra GitHub repository and releases&lt;/a&gt;: the framework behind &lt;code&gt;kubectl&lt;/code&gt; and &lt;code&gt;gh&lt;/code&gt;, current v1.10.x line&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/urfave/cli" rel="noopener noreferrer"&gt;urfave/cli GitHub repository&lt;/a&gt;: v3 releases and migration notes from v2&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/charmbracelet/bubbletea" rel="noopener noreferrer"&gt;Bubble Tea GitHub repository and releases&lt;/a&gt;: Elm-architecture TUI framework, v2 breaking changes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/charmbracelet/bubbletea/releases/tag/v2.0.0" rel="noopener noreferrer"&gt;Bubble Tea v2.0.0 release notes&lt;/a&gt;: the key-message split, &lt;code&gt;tea.View&lt;/code&gt; return type, and import-path change&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://charm.sh/" rel="noopener noreferrer"&gt;Charm&lt;/a&gt;: Lip Gloss, Bubbles, and Huh, the styling and component layer around Bubble Tea&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;The useful question is what your tool actually does, not which library wins a benchmark. If it parses commands, the choice is Cobra versus urfave/cli, and it comes down to how deep your subcommand tree goes and whether you want Viper config: Cobra for depth, urfave/cli for lightness. If it draws a screen, reach for Bubble Tea, which sits on top of whichever parser you chose rather than competing with it. My deploy tool ended up Cobra plus Bubble Tea, and rebuilding it three times was the fastest way I've found to stop guessing and just pick.&lt;/p&gt;

</description>
      <category>go</category>
      <category>cli</category>
      <category>cobra</category>
      <category>bubbletea</category>
    </item>
    <item>
      <title>Kimi K3 Review: The 2.8T Open Model That Beats Claude on Paper</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Wed, 22 Jul 2026 08:45:26 +0000</pubDate>
      <link>https://dev.to/dmaxdev/kimi-k3-review-the-28t-open-model-that-beats-claude-on-paper-oog</link>
      <guid>https://dev.to/dmaxdev/kimi-k3-review-the-28t-open-model-that-beats-claude-on-paper-oog</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Kimi K3 is the largest open-weight model anyone has shipped: 2.8 trillion parameters, a million-token context, and a price that undercuts Claude by roughly 3x. On the leaderboards it beats Claude Fable 5 at frontend coding and lands third overall on GDPval, which is genuinely impressive for weights you can download. After a week of feeding it real Go and Python work, the picture got messier. It's slow, it invents APIs that don't exist more often than any frontier model I've used this year, and most of its headline numbers are still Moonshot's own. Worth running for cost-sensitive batch work; I would not hand it an unsupervised agent loop yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I bothered testing this one
&lt;/h2&gt;

&lt;p&gt;I review a lot of models and most open-weight releases don't survive contact with a real repo. &lt;a href="https://www.danilchenko.dev/posts/deepseek-v4-pro-review/" rel="noopener noreferrer"&gt;DeepSeek V4 Pro&lt;/a&gt; and &lt;a href="https://www.danilchenko.dev/posts/glm-5-2-review/" rel="noopener noreferrer"&gt;GLM-5.2&lt;/a&gt; were the two that did. Both were cheap, competent, and good enough that I kept them in rotation for grunt work. When Moonshot dropped Kimi K3 on July 16 and the first benchmark screenshots showed it &lt;em&gt;ahead&lt;/em&gt; of Claude Fable 5, I moved it to the top of my testing queue.&lt;/p&gt;

&lt;p&gt;I ran K3 through the API (OpenRouter forwards to Moonshot's single hosted endpoint right now) for about a week: a Go service refactor, a pandas-to-Polars migration, a batch of PR reviews, and the usual pile of "explain this stack trace" prompts. I paid for the tokens myself, so the cost math below is real. What follows is what the leaderboards don't tell you: where a 2.8T open model actually helps, and where it quietly wastes your afternoon.&lt;/p&gt;

&lt;p&gt;One caveat up front, and it's a big one: at the time of writing, the open weights aren't public yet. Moonshot says they land July 27, ten days after launch, under a modified MIT-style license. Everything in this review is based on the hosted API. If the weight release slips or the license has teeth, half the appeal evaporates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Kimi K3 actually is
&lt;/h2&gt;

&lt;p&gt;Kimi K3 is a Mixture-of-Experts model with about 2.8 trillion total parameters. Moonshot reports 896 experts with 16 active per token, which is why a model this size can serve at Sonnet-ish prices: you only pay to run a sliver of it on each forward pass. The context window is 1,048,576 tokens, the same 1M ballpark the Gemini line reaches, and it's tuned hard for long-horizon coding and agent loops rather than chat.&lt;/p&gt;

&lt;p&gt;At launch there are two variants: &lt;strong&gt;K3 Max&lt;/strong&gt; for interactive chat and agent tasks, and &lt;strong&gt;K3 Swarm Max&lt;/strong&gt; for large-scale parallel processing. Most people will only touch K3 Max. Swarm Max is aimed at the "fan out 200 requests and merge" workloads that batch pipelines and eval harnesses generate.&lt;/p&gt;

&lt;p&gt;The "largest open-weight model ever" line is doing a lot of marketing work, but it's accurate. Nothing else with downloadable weights comes close to 2.8T. Whether you can &lt;em&gt;run&lt;/em&gt; it locally is a separate question I'll get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it running
&lt;/h2&gt;

&lt;p&gt;The API is a plain OpenAI-compatible endpoint, so if you already have code hitting GPT or Claude, swapping in K3 is a two-line change. Here's the exact call I used to smoke-test it:&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;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&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;https://openrouter.ai/api/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="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPENROUTER_API_KEY&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;resp&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;moonshotai/kimi-k3&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="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;system&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;You are a senior Go engineer. Be terse.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;Write a worker pool in Go 1.26 that &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processes a channel of jobs with N workers &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;and returns the first error via errgroup.&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;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&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;resp&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;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tokens:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output on my run (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;package main

import (
    "context"
    "golang.org/x/sync/errgroup"
)

func Process(ctx context.Context, jobs &amp;lt;-chan Job, n int) error {
    g, ctx := errgroup.WithContext(ctx)
    for i := 0; i &amp;lt; n; i++ {
        g.Go(func() error {
            for {
                select {
                case &amp;lt;-ctx.Done():
                    return ctx.Err()
                case job, ok := &amp;lt;-jobs:
                    if !ok {
                        return nil
                    }
                    if err := job.Do(ctx); err != nil {
                        return err
                    }
                }
            }
        })
    }
    return g.Wait()
}

tokens: 512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's correct, idiomatic, and closes over the loop variable safely (Go 1.22+ semantics). No complaints on the code itself. The complaint is what the timer showed: that response took noticeably longer than the same prompt to Fable 5 or GPT-5.6. Which brings me to the thing the leaderboards bury.&lt;/p&gt;

&lt;h2&gt;
  
  
  The speed problem is real
&lt;/h2&gt;

&lt;p&gt;Every hands-on account I've seen, including mine, lands on the same finding: K3 is slow. &lt;a href="https://thenewstack.io/kimi-k3-fable-coding-benchmark/" rel="noopener noreferrer"&gt;The New Stack ran a coding comparison&lt;/a&gt; and clocked it at roughly &lt;strong&gt;4x slower&lt;/strong&gt; than Claude Fable 5 on equivalent tasks. My informal timings weren't that clean, but "meaningfully slower" is not in dispute.&lt;/p&gt;

&lt;p&gt;For an interactive agent loop, 4x slower is disqualifying. When I'm iterating with a coding agent, latency is the whole experience. A tool call that takes six seconds instead of two turns a ten-minute session into forty. I stopped using K3 for anything conversational within a day.&lt;/p&gt;

&lt;p&gt;Where the slowness stops mattering is batch work. If you're running a nightly job that summarizes 5,000 support tickets or generates first-draft docstrings across a monorepo, nobody's watching the clock. There, K3's price does the talking. That split defines the whole model: great for offline, painful for interactive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmarks, with the asterisks attached
&lt;/h2&gt;

&lt;p&gt;K3 earned its headlines on the benchmarks. &lt;a href="https://venturebeat.com/technology/chinas-moonshot-ai-releases-kimi-k3-the-largest-open-source-model-ever-rivaling-top-u-s-systems" rel="noopener noreferrer"&gt;As VentureBeat's launch coverage laid out&lt;/a&gt;, these are the numbers being quoted, and I'm labeling every one of them as &lt;strong&gt;reported&lt;/strong&gt;, because most trace back to Moonshot's own eval runs or single-leaderboard placements rather than broad independent replication.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;Kimi K3 (reported)&lt;/th&gt;
&lt;th&gt;For reference&lt;/th&gt;
&lt;th&gt;Placement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend Code Arena&lt;/td&gt;
&lt;td&gt;1,679&lt;/td&gt;
&lt;td&gt;~48 pts ahead of Claude Fable 5&lt;/td&gt;
&lt;td&gt;1st&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GDPval-AA v2&lt;/td&gt;
&lt;td&gt;~1,668&lt;/td&gt;
&lt;td&gt;Fable 5 Max ~1,760 · GPT-5.6 Sol Max ~1,748 · Opus 4.8 1,600&lt;/td&gt;
&lt;td&gt;3rd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AA-Briefcase&lt;/td&gt;
&lt;td&gt;~1,547&lt;/td&gt;
&lt;td&gt;Fable 5 Max ~1,583 · GPT-5.6 Sol Max 1,495&lt;/td&gt;
&lt;td&gt;2nd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SWE-bench Verified&lt;/td&gt;
&lt;td&gt;~65–67%&lt;/td&gt;
&lt;td&gt;Same tier as top OpenAI/Anthropic models&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things stand out. First, beating Claude Fable 5 on the &lt;a href="https://www.tomshardware.com/tech-industry/artificial-intelligence/moonshot-releases-2-8-trillion-parameter-kimi-k3" rel="noopener noreferrer"&gt;Frontend Code Arena&lt;/a&gt; is a real result. Arena runs are human-preference votes on built UIs, and that one leaderboard is independent of Moonshot. Second, on GDPval it lands &lt;em&gt;behind&lt;/em&gt; the top closed models but ahead of Opus 4.8, which is the fair "third place, and that's still very good for open weights" framing.&lt;/p&gt;

&lt;p&gt;The SWE-bench figure needs the most salt. "65–67% in scaffold-assisted settings" means with a coding harness doing retries and test feedback around it. Bare-model SWE-bench numbers are always lower, and Moonshot hasn't published a clean methodology at the time of writing. Treat it as "strong tier," not a precise ranking against a specific competitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kimi K3 vs Claude vs GPT: the head-to-head
&lt;/h2&gt;

&lt;p&gt;Most people searching for K3 are really asking one question: should I use this instead of Claude or GPT? Here's the head-to-head that reflects a week of use plus the public data.&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;Kimi K3&lt;/th&gt;
&lt;th&gt;Claude Fable 5&lt;/th&gt;
&lt;th&gt;GPT-5.6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weights&lt;/td&gt;
&lt;td&gt;Open (July 27)&lt;/td&gt;
&lt;td&gt;Closed&lt;/td&gt;
&lt;td&gt;Closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context&lt;/td&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;~400K (est.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input price / 1M&lt;/td&gt;
&lt;td&gt;$3 ($0.30 cache-hit)&lt;/td&gt;
&lt;td&gt;~$10&lt;/td&gt;
&lt;td&gt;Premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output price / 1M&lt;/td&gt;
&lt;td&gt;$15&lt;/td&gt;
&lt;td&gt;~$50&lt;/td&gt;
&lt;td&gt;Premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agentic reliability&lt;/td&gt;
&lt;td&gt;Uneven&lt;/td&gt;
&lt;td&gt;Best in class&lt;/td&gt;
&lt;td&gt;Very good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Cheap batch, self-hosting later&lt;/td&gt;
&lt;td&gt;Interactive coding, agents&lt;/td&gt;
&lt;td&gt;General reasoning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;K3 and Fable 5 prices are published/reported rates; GPT-5.6 figures are approximate.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On raw coding quality for a single well-scoped task, K3 is close enough to Fable 5 that you would not reliably tell them apart in a blind read. The New Stack's summary of "same results, one-third the cost, four times slower" matches my experience almost exactly: Fable 5 runs about $10 in and $50 out per million tokens, so K3's $3/$15 really is the cheaper column. If your bottleneck is API spend and not wall-clock time, that trade is a bargain.&lt;/p&gt;

&lt;p&gt;The gap opens up in agent loops. Fable 5 and GPT-5.6 recover from their own mistakes; they read a failing test, form a theory, and fix it. K3 is more likely to double down on a wrong assumption, which is a known failure mode I dug into in the piece on &lt;a href="https://www.danilchenko.dev/posts/2026-04-01-error-cascades-multi-agent-llm-systems/" rel="noopener noreferrer"&gt;multi-agent error cascades&lt;/a&gt;. If you want the current best-in-class agent, the &lt;a href="https://www.danilchenko.dev/posts/claude-fable-5-review/" rel="noopener noreferrer"&gt;Claude Fable 5 review&lt;/a&gt; still holds. K3 wins on price; Fable 5 wins on recovering from its own mistakes. Anyone doing head-to-heads through the Anthropic API will feel that difference within a session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it falls apart: hallucination
&lt;/h2&gt;

&lt;p&gt;This is the part that kept K3 out of my daily rotation. &lt;a href="https://memeburn.com/kimi-k3-beat-claude-and-gpt-at-coding-why-cant-you-fully-trust-it/" rel="noopener noreferrer"&gt;Independent testing has put its hallucination rate around &lt;strong&gt;51%&lt;/strong&gt;&lt;/a&gt;, up from a reported ~39% on its predecessor. I don't have a way to reproduce that exact figure, but the qualitative pattern showed up fast.&lt;/p&gt;

&lt;p&gt;Twice in a week, K3 confidently used APIs that don't exist. In one pandas migration it wrote &lt;code&gt;pd.read_parquet(path, engine="polars")&lt;/code&gt; as though pandas ships a Polars backend — it doesn't. In a Go task it referenced a stdlib helper that isn't in the standard library at all. Both times the surrouding code was clean and plausible, which is exactly what makes it dangerous: the wrong line reads as confidently as the right ones. A less careful reviewer merges it.&lt;/p&gt;

&lt;p&gt;For a human-in-the-loop workflow where you read every diff, this is an annoyance you can catch. For an autonomous agent that writes, runs, and commits without a person checking each step, a coin-flip hallucination rate is a hard stop. The benchmark wins and this number live in tension, and you can't take one seriously without the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing and how to run it
&lt;/h2&gt;

&lt;p&gt;Through the API, K3 &lt;a href="https://openrouter.ai/moonshotai/kimi-k3" rel="noopener noreferrer"&gt;costs $3 per million input tokens&lt;/a&gt; on a cache miss, $0.30 per million on a cache hit, and $15 per million output tokens. That's Sonnet-class pricing for a model posting near-frontier coding scores, which is the entire pitch.&lt;/p&gt;

&lt;p&gt;Local is the harder story. The weights drop July 27, but 2.8T parameters is not something you run on a workstation. Even at MoE's reduced active-parameter cost, you need serious multi-GPU hardware or a quantized community build to load it at all. "Kimi K3 on Ollama" is one of the top autocompletes for the model, and the real answer for now is: not on consumer hardware, and not on day one. Watch the Hugging Face repo and the quantization community; a usable GGUF will show up eventually, but it won't be quick. If you want a model you can actually run on a Mac today, &lt;a href="https://www.danilchenko.dev/posts/apfel-review-free-local-ai-mac/" rel="noopener noreferrer"&gt;Apfel&lt;/a&gt; is a far more realistic pick. Different weight class, but it fits in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should use it, who should skip
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use Kimi K3 if:&lt;/strong&gt; you run large offline batch jobs where cost dominates and latency doesn't, you want to hedge against closed-model lock-in, or you plan to self-host once the weights and a viable serving stack exist. The price-per-quality on non-interactive work is the best I've measured this quarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip it if:&lt;/strong&gt; you want a snappy interactive coding assistant (Fable 5 or GPT-5.6, no contest), you're building an unsupervised agent where a hallucination becomes a committed bug, or you need a model you can run locally this week. For most solo developers writing code interactively, the slowness alone rules it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Kimi K3 open source?
&lt;/h3&gt;

&lt;p&gt;The weights are open; the training data and full pipeline are not. Moonshot says the weights ship July 27, 2026 under a modified MIT-style license (the same approach as Kimi K2.6), which makes K3 open-weight rather than fully open-source. You can download and run the model, but you can't fully reproduce it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Kimi K3 better than Claude?
&lt;/h3&gt;

&lt;p&gt;On some coding leaderboards, yes: it reportedly tops the Frontend Code Arena ahead of Claude Fable 5. In day-to-day use it's competitive on single-task code quality but slower and more prone to hallucination, so Claude remains the stronger pick for interactive and agentic work.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does Kimi K3 cost?
&lt;/h3&gt;

&lt;p&gt;API pricing is $3 per million input tokens ($0.30 on cache hits) and $15 per million output tokens, roughly a third of what comparable closed models charge for similar coding quality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you run Kimi K3 on Ollama or locally?
&lt;/h3&gt;

&lt;p&gt;Not on consumer hardware. At 2.8T total parameters it needs substantial multi-GPU infrastructure even as a Mixture-of-Experts model. Quantized community builds may make smaller-scale local runs possible after the July 27 weight release, but there's nothing practical for a laptop at launch.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many parameters does Kimi K3 have?
&lt;/h3&gt;

&lt;p&gt;About 2.8 trillion total parameters in a Mixture-of-Experts design, with roughly 896 experts and 16 active per token, so only a small fraction runs on any given forward pass, which is what keeps inference costs down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://openrouter.ai/moonshotai/kimi-k3" rel="noopener noreferrer"&gt;Kimi K3 on OpenRouter&lt;/a&gt; — official API pricing and hosting details&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://venturebeat.com/technology/chinas-moonshot-ai-releases-kimi-k3-the-largest-open-source-model-ever-rivaling-top-u-s-systems" rel="noopener noreferrer"&gt;China's Moonshot AI releases Kimi K3, the largest open-source model ever — VentureBeat&lt;/a&gt; — launch coverage, specs, and benchmark placements&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.tomshardware.com/tech-industry/artificial-intelligence/moonshot-releases-2-8-trillion-parameter-kimi-k3" rel="noopener noreferrer"&gt;Moonshot releases 2.8-trillion-parameter Kimi K3 — Tom's Hardware&lt;/a&gt; — architecture and Frontend Code Arena result&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://thenewstack.io/kimi-k3-fable-coding-benchmark/" rel="noopener noreferrer"&gt;Claude Fable 5 vs Kimi K3: same results, one-third the cost, 4x slower — The New Stack&lt;/a&gt; — independent coding comparison, latency, and pricing&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.datacamp.com/blog/kimi-k3" rel="noopener noreferrer"&gt;Kimi K3 — DataCamp&lt;/a&gt; — model overview, variants, and open-weights timeline&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://memeburn.com/kimi-k3-beat-claude-and-gpt-at-coding-why-cant-you-fully-trust-it/" rel="noopener noreferrer"&gt;Kimi K3 beat Claude and GPT at coding — why can't you fully trust it? — Memeburn&lt;/a&gt; — reported hallucination-rate testing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Kimi K3 is the most impressive open-weight release of the year and, for a specific job, a genuine bargain. If your workload is offline, cost-bound, and human-reviewed, download it the moment the weights land and point your batch jobs at it. But the leaderboard story and the daily-driver story are two different reviews. On the benchmarks it beats Claude; at my keyboard it was slow and it lied to me twice in a week. I'll keep it for batch grunt work and reach for Fable 5 the moment a task actually needs to be right.&lt;/p&gt;

</description>
      <category>kimik3</category>
      <category>moonshotai</category>
      <category>openweightmodels</category>
      <category>aicoding</category>
    </item>
    <item>
      <title>Go's JSON v2 (encoding/json/v2): Faster, Stricter, and What Breaks</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Sun, 19 Jul 2026 08:41:43 +0000</pubDate>
      <link>https://dev.to/dmaxdev/gos-json-v2-encodingjsonv2-faster-stricter-and-what-breaks-f4f</link>
      <guid>https://dev.to/dmaxdev/gos-json-v2-encodingjsonv2-faster-stricter-and-what-breaks-f4f</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Go's new &lt;code&gt;encoding/json/v2&lt;/code&gt; is the first real rewrite of the standard library's JSON handling in fifteen years. It's a reported 2–10x faster at unmarshaling, cleans up a pile of long-standing footguns, and moves configuration from magic struct tags into explicit function options. The catch: it changes several v1 defaults you may be quietly relying on — nil slices now encode as &lt;code&gt;[]&lt;/code&gt; instead of &lt;code&gt;null&lt;/code&gt;, duplicate object keys are rejected, and field matching is case-sensitive. As of Go 1.26 it lives behind &lt;code&gt;GOEXPERIMENT=jsonv2&lt;/code&gt;, with the plan to make it the default in Go 1.27. This is a practical walkthrough of turning it on, the changes that will bite you, and how to migrate without breaking your API contracts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a second JSON package at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;encoding/json&lt;/code&gt; shipped with Go 1.0 in 2012 and barely changed since. That stability paid off for a long time, then slowly turned into a tax. The v1 package carried a set of decisions that looked reasonable in 2011 and aged into daily annoyances: it silently accepts duplicate object keys, replaces invalid UTF-8 with the Unicode replacement character instead of erroring, and marshals a &lt;code&gt;nil&lt;/code&gt; slice as &lt;code&gt;null&lt;/code&gt; rather than an empty array. Each of those has bitten real production code, including mine.&lt;/p&gt;

&lt;p&gt;The one that cost me an afternoon was the nil-slice-as-&lt;code&gt;null&lt;/code&gt; behavior. I had a Go service returning a list of items to a TypeScript frontend. When the list was empty, the backend sometimes sent &lt;code&gt;"items": null&lt;/code&gt; and sometimes &lt;code&gt;"items": []&lt;/code&gt;, depending on whether a slice had been initialized or left nil somewhere upstream. The frontend called &lt;code&gt;.map()&lt;/code&gt; on the response and blew up only on the &lt;code&gt;null&lt;/code&gt; path. It was an intermittent, hard-to-reproduce crash that came down to a Go zero-value quirk. I fixed it back then with a &lt;code&gt;make([]Item, 0)&lt;/code&gt; and a mental note. v2 fixes it at the source.&lt;/p&gt;

&lt;p&gt;That is the spirit of the whole package. The &lt;a href="https://go.dev/blog/jsonv2-exp" rel="noopener noreferrer"&gt;official experimental announcement&lt;/a&gt; frames it as fixing "15 years of accumulated sharp edges" while keeping the API recognizable. If you've written Go, &lt;code&gt;json.Marshal&lt;/code&gt; and &lt;code&gt;json.Unmarshal&lt;/code&gt; still work the way you expect. They just have saner defaults and a much faster engine underneath.&lt;/p&gt;

&lt;p&gt;If you're tracking where the language is heading more broadly, this fits the same evolutionary arc as &lt;a href="https://www.danilchenko.dev/posts/go-1-26-features/" rel="noopener noreferrer"&gt;Go 1.26's other standard-library changes&lt;/a&gt;: fewer surprises, more explicit control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually in the box
&lt;/h2&gt;

&lt;p&gt;json/v2 is not one package but two, split along a clean line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;encoding/json/jsontext&lt;/code&gt;&lt;/strong&gt; handles &lt;em&gt;syntax&lt;/em&gt;: the raw grammar of JSON. It gives you a streaming &lt;code&gt;Encoder&lt;/code&gt; and &lt;code&gt;Decoder&lt;/code&gt;, plus &lt;code&gt;Value&lt;/code&gt; and &lt;code&gt;Token&lt;/code&gt; types, and it doesn't touch reflection at all. Think of it as the tokenizer layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;encoding/json/v2&lt;/code&gt;&lt;/strong&gt; handles &lt;em&gt;semantics&lt;/em&gt;: mapping Go values to and from JSON. This is where &lt;code&gt;Marshal&lt;/code&gt;, &lt;code&gt;Unmarshal&lt;/code&gt;, and the struct-tag machinery live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That separation pays off in performance-sensitive code, because you can drop down to &lt;code&gt;jsontext&lt;/code&gt; and stream tokens without paying for reflection when you don't need it. For everyday use you'll import v2 and never think about the split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it on
&lt;/h2&gt;

&lt;p&gt;On Go 1.25 or 1.26, the packages are hidden unless you build with the experiment flag. There's no &lt;code&gt;go get&lt;/code&gt; and no third-party module. It's already in your toolchain, just gated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One-off run&lt;/span&gt;
&lt;span class="nv"&gt;GOEXPERIMENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;jsonv2 go run main.go

&lt;span class="c"&gt;# Persist it for a session&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOEXPERIMENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;jsonv2
go build ./...
go &lt;span class="nb"&gt;test&lt;/span&gt; ./...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the flag is set, the import paths become visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// main.go — run with: GOEXPERIMENT=jsonv2 go run main.go&lt;/span&gt;
&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;

    &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="s"&gt;"encoding/json/v2"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;    &lt;span class="kt"&gt;int&lt;/span&gt;      &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
    &lt;span class="n"&gt;Roles&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"roles"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Ada"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;// Roles left nil on purpose&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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;Run it and you get:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Ada"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"roles"&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;Under v1, that same struct marshals to &lt;code&gt;"roles":null&lt;/code&gt;. That single difference is the one most likely to change bytes on the wire in your service, so it's worth internalizing before anything else.&lt;/p&gt;

&lt;p&gt;If you can't move off an older Go toolchain yet, the same code is available today as a standalone module, &lt;code&gt;github.com/go-json-experiment/json&lt;/code&gt;, which is the upstream where v2 was developed. The import path differs but the API is the same, so it's a fair way to experiment on Go 1.23 or 1.24 before your production toolchain catches up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The breaking changes, ranked by how likely they are to bite
&lt;/h2&gt;

&lt;p&gt;Here's the full set of default changes, most disruptive first. Every one of them is controllable. v2 keeps the old behavior available as an option; it's just no longer the default.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;v1 default&lt;/th&gt;
&lt;th&gt;v2 default&lt;/th&gt;
&lt;th&gt;How to restore v1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nil slice&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;json.FormatNilSliceAsNull(true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nil map&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;json.FormatNilMapAsNull(true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field matching&lt;/td&gt;
&lt;td&gt;case-insensitive&lt;/td&gt;
&lt;td&gt;case-sensitive&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;json.MatchCaseInsensitiveNames(true)&lt;/code&gt; or &lt;code&gt;case:ignore&lt;/code&gt; tag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate object keys&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;rejected&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jsontext.AllowDuplicateNames(true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid UTF-8&lt;/td&gt;
&lt;td&gt;replaced silently&lt;/td&gt;
&lt;td&gt;rejected&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jsontext.AllowInvalidUTF8(true)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;time.Duration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;encoded as int64 nanoseconds&lt;/td&gt;
&lt;td&gt;requires explicit &lt;code&gt;format&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;json:",format:nano"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Case sensitivity is the sneaky one
&lt;/h3&gt;

&lt;p&gt;The nil-slice change is loud. You'll see it in the first test that checks a response body. Case sensitivity is quiet, because it fails by &lt;em&gt;silently not matching&lt;/em&gt; rather than by erroring. Consider input from an API that uses &lt;code&gt;SCREAMING_CASE&lt;/code&gt; or &lt;code&gt;PascalCase&lt;/code&gt; keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`{"NAME":"Ada"}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="c"&gt;// Name is tagged json:"name"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;panic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Name=%q&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name=""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In v1 that would have populated &lt;code&gt;u.Name&lt;/code&gt; with &lt;code&gt;"Ada"&lt;/code&gt; because matching ignored case. In v2 the field stays at its zero value and no error is raised. If you consume upstream JSON whose casing doesn't exactly match your tags, opt back in per-field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"name,case:ignore"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or globally at the call site with &lt;code&gt;json.MatchCaseInsensitiveNames(true)&lt;/code&gt;. I'd reach for the per-field tag, since it documents which fields have loose upstream contracts instead of loosening the whole struct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duplicate keys now error
&lt;/h3&gt;

&lt;p&gt;v1 happily unmarshaled &lt;code&gt;{"id":1,"id":2}&lt;/code&gt; and kept the last value. That's a genuine security hole: different parsers picking different values for the same key is the root of a whole class of request-smuggling and access-control bugs. v2 rejects it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`{"id":1,"id":2}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;
&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error message is descriptive (something along the lines of a duplicate object member name for &lt;code&gt;"id"&lt;/code&gt;; the exact wording may shift while the package is experimental). If you have a legitimate reason to accept duplicates, passing &lt;code&gt;jsontext.AllowDuplicateNames(true)&lt;/code&gt; restores the old leniency (that option and &lt;code&gt;AllowInvalidUTF8&lt;/code&gt; live in the &lt;code&gt;jsontext&lt;/code&gt; package, since they're syntactic, but you pass them straight to &lt;code&gt;json.Unmarshal&lt;/code&gt; like any other option). Think hard before you reach for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Options replace tag soup
&lt;/h2&gt;

&lt;p&gt;The change I like most has nothing to do with speed. In v1, everything was a struct tag or a global, and anything you couldn't express as a tag you couldn't express at all. v2 makes options first-class function arguments that you pass to &lt;code&gt;Marshal&lt;/code&gt; and &lt;code&gt;Unmarshal&lt;/code&gt; directly. The &lt;a href="https://pkg.go.dev/encoding/json/v2" rel="noopener noreferrer"&gt;package reference&lt;/a&gt; lists the full set.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatNilSliceAsNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c"&gt;// keep v1 null-for-nil&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deterministic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;        &lt;span class="c"&gt;// stable map key ordering&lt;/span&gt;
    &lt;span class="n"&gt;jsontext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithIndent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  "&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;       &lt;span class="c"&gt;// pretty-print&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Deterministic(true)&lt;/code&gt; option is a small quality-of-life win worth calling out: it guarantees identical inputs produce identical output bytes, which makes golden-file tests and content hashing reliable without you sorting maps by hand. You can bundle options once and reuse them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;prettyV1&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JoinOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatNilSliceAsNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatNilMapAsNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;jsontext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithIndent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&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;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prettyV1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The struct-tag vocabulary grew too. The most useful new tag is &lt;code&gt;omitzero&lt;/code&gt;, which finally fixes the ancient confusion around &lt;code&gt;omitempty&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;    &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
    &lt;span class="n"&gt;Start&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt; &lt;span class="s"&gt;`json:"start,omitzero"`&lt;/span&gt;  &lt;span class="c"&gt;// dropped when the zero Time&lt;/span&gt;
    &lt;span class="n"&gt;Tags&lt;/span&gt;  &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;  &lt;span class="s"&gt;`json:"tags,omitempty"`&lt;/span&gt;  &lt;span class="c"&gt;// dropped when empty JSON ([], "", null)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;omitempty&lt;/code&gt; in v1 was notorious for not omitting a zero &lt;code&gt;time.Time&lt;/code&gt; (because a struct is never "empty" by its rule). &lt;code&gt;omitzero&lt;/code&gt; checks the Go zero value (or an &lt;code&gt;IsZero()&lt;/code&gt; method if your type has one), which is almost always what people meant in the first place. If you've ever shipped &lt;code&gt;"start":"0001-01-01T00:00:00Z"&lt;/code&gt; into a JSON response by accident, this tag is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming and the O(n²) trap
&lt;/h2&gt;

&lt;p&gt;The headline performance number is unmarshal speed, but the more interesting fix is structural. In v1, if a custom type implemented &lt;code&gt;MarshalJSON&lt;/code&gt;, encoding a large slice of that type could degrade to O(n²): each element's &lt;code&gt;MarshalJSON&lt;/code&gt; allocated a fresh &lt;code&gt;[]byte&lt;/code&gt;, which then got re-parsed and re-copied into the parent buffer. Nest that a couple of levels deep and throughput falls off a cliff.&lt;/p&gt;

&lt;p&gt;v2 adds streaming custom-marshaler interfaces that write directly into the shared encoder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Old, allocation-heavy:&lt;/span&gt;
&lt;span class="c"&gt;//   func (t T) MarshalJSON() ([]byte, error)&lt;/span&gt;
&lt;span class="c"&gt;//&lt;/span&gt;
&lt;span class="c"&gt;// New, streams into the parent encoder:&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;MarshalJSONTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;jsontext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encoder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsontext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&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;If you'd rather not add methods to a type, v2 also takes function-based marshalers registered per type through &lt;code&gt;json.WithMarshalers&lt;/code&gt;. Those helpers lean on &lt;a href="https://www.danilchenko.dev/posts/go-generic-methods/" rel="noopener noreferrer"&gt;Go generics&lt;/a&gt; so you get a type-safe &lt;code&gt;func(T)&lt;/code&gt; handler instead of the old &lt;code&gt;interface{}&lt;/code&gt; juggling. For bulk output you can drive the encoder yourself and avoid building a giant in-memory value at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="s"&gt;"encoding/json/v2"&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json/jsontext"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;jsontext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;users&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MarshalEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decode side mirrors it with &lt;code&gt;json.UnmarshalDecode&lt;/code&gt; over a &lt;code&gt;jsontext.Decoder&lt;/code&gt;, which pairs naturally with a range loop the way &lt;a href="https://www.danilchenko.dev/posts/go-iterators/" rel="noopener noreferrer"&gt;Go's iterators&lt;/a&gt; do: read one value, process it, discard it, without holding the whole document in memory. The Go team reported that Kubernetes' kube-openapi saw "orders of magnitude" improvement switching a hot path to the streaming interface. That's the kind of gain you only get from fixing the algorithmic shape of the problem, and no amount of parser tuning would have matched it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarking it yourself
&lt;/h2&gt;

&lt;p&gt;I want to be precise here: I did not run these numbers on my own hardware, because the machine I wrote this on is still on Go 1.24 and the package needs 1.25+. The 2–10x figure comes from the &lt;a href="https://go.dev/blog/jsonv2-exp" rel="noopener noreferrer"&gt;Go team's own benchmarks&lt;/a&gt; and third-party measurements, and I'm labeling it as reported rather than measured. Marshal is roughly at parity with v1; the big wins are on the unmarshal path, where the new parser does far less work.&lt;/p&gt;

&lt;p&gt;Don't take anyone's benchmark on faith, including mine. Here's a harness you can drop into your own repo and run against your actual payloads, which is the only benchmark worth trusting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;bench&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
    &lt;span class="s"&gt;"testing"&lt;/span&gt;

    &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="s"&gt;"encoding/json/v2"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;BenchmarkUnmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testdata/payload.json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// your real data&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReportAllocs&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;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GOEXPERIMENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;jsonv2 go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-bench&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Unmarshal &lt;span class="nt"&gt;-benchmem&lt;/span&gt; ./bench/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same benchmark once with the flag and once without (swapping the import back to &lt;code&gt;encoding/json&lt;/code&gt;), and compare with &lt;code&gt;benchstat&lt;/code&gt;. Your mileage depends heavily on the shape of your data. Documents with lots of small objects and string-heavy payloads tend to show the largest gap, while a handful of big numeric arrays will show less.&lt;/p&gt;

&lt;h2&gt;
  
  
  A safe migration plan
&lt;/h2&gt;

&lt;p&gt;The good news is that v2 is designed for gradual adoption. v1 is being reimplemented on top of v2 internally, and the two are meant to coexist for years. The Go team has said v1 won't be deprecated for years after v2 stabilizes, so you don't have to flip everything at once. Here's the order I'd follow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Turn on the flag in CI first, not prod.&lt;/strong&gt; Build and run your full test suite with &lt;code&gt;GOEXPERIMENT=jsonv2&lt;/code&gt;. Most failures will be golden-file or response-body assertions catching the nil-slice and case-sensitivity changes. That's your blast-radius report, for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix contracts, not symptoms.&lt;/strong&gt; Where a test breaks because a nil slice now encodes as &lt;code&gt;[]&lt;/code&gt;, decide which representation your API actually promises. Usually &lt;code&gt;[]&lt;/code&gt; is correct and the test was wrong; occasionally a client depends on &lt;code&gt;null&lt;/code&gt; and you set &lt;code&gt;FormatNilSliceAsNull(true)&lt;/code&gt; at that boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;case:ignore&lt;/code&gt; where upstream casing is loose.&lt;/strong&gt; Audit every place you unmarshal third-party JSON and tag the fields whose casing you don't control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adopt &lt;code&gt;omitzero&lt;/code&gt;&lt;/strong&gt; in place of the &lt;code&gt;omitempty&lt;/code&gt;-on-a-struct workarounds you've accumulated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave the streaming interfaces for later.&lt;/strong&gt; They're an optimization you can defer safely. Convert the one or two hot paths that profiling flags, and leave the rest on plain &lt;code&gt;Marshal&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you build data-heavy services, this is the same performance-versus-safety tradeoff conversation that plays out in Python land with &lt;a href="https://www.danilchenko.dev/posts/msgspec-vs-pydantic/" rel="noopener noreferrer"&gt;msgspec versus Pydantic&lt;/a&gt;: a stricter, faster serializer that asks you to be explicit about your data's shape. Go just happens to be baking that upgrade into the standard library instead of a third-party package.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Go json v2 stable and production-ready?
&lt;/h3&gt;

&lt;p&gt;Not yet, officially. As of Go 1.26 it's experimental, gated behind &lt;code&gt;GOEXPERIMENT=jsonv2&lt;/code&gt;, and the API can still change. The plan is to graduate it to the default in Go 1.27. Plenty of teams are already testing it in CI, but I wouldn't gate a production release on experimental behavior that might shift.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between encoding/json and encoding/json/v2?
&lt;/h3&gt;

&lt;p&gt;Same core API (&lt;code&gt;Marshal&lt;/code&gt;/&lt;code&gt;Unmarshal&lt;/code&gt;), but v2 has stricter, safer defaults (rejects duplicate keys and invalid UTF-8, matches field names case-sensitively), encodes nil slices/maps as &lt;code&gt;[]&lt;/code&gt;/&lt;code&gt;{}&lt;/code&gt; instead of &lt;code&gt;null&lt;/code&gt;, adds first-class option arguments, introduces the &lt;code&gt;omitzero&lt;/code&gt; tag, and ships a much faster unmarshaler.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much faster is Go json v2?
&lt;/h3&gt;

&lt;p&gt;Reported benchmarks put unmarshaling at roughly 2–10x faster than v1, driven by a more efficient parser. Marshaling is about the same speed as v1. The gains depend on your data shape, so benchmark your own payloads rather than trusting a single headline number.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to rewrite my structs to use json v2?
&lt;/h3&gt;

&lt;p&gt;No. Existing struct tags keep working. You only touch code where a changed default affects your output: restoring &lt;code&gt;null&lt;/code&gt; for nil slices at a specific boundary, adding &lt;code&gt;case:ignore&lt;/code&gt; for loosely-cased upstreams, or swapping &lt;code&gt;omitempty&lt;/code&gt; for &lt;code&gt;omitzero&lt;/code&gt;. Everything else is a recompile.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use encoding/json/v2 on older Go versions?
&lt;/h3&gt;

&lt;p&gt;The standard-library path needs Go 1.25 or newer with the experiment flag. If you're stuck on Go 1.23 or 1.24, the identical implementation is available as the standalone module &lt;code&gt;github.com/go-json-experiment/json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://go.dev/blog/jsonv2-exp" rel="noopener noreferrer"&gt;A new experimental Go API for JSON — The Go Blog&lt;/a&gt; — the official announcement, rationale, and behavior changes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pkg.go.dev/encoding/json/v2" rel="noopener noreferrer"&gt;encoding/json/v2 — Go Packages&lt;/a&gt; — full API reference for functions, options, and struct tags&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pkg.go.dev/encoding/json/jsontext" rel="noopener noreferrer"&gt;encoding/json/jsontext — Go Packages&lt;/a&gt; — the syntactic streaming layer&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/go-json-experiment/json" rel="noopener noreferrer"&gt;go-json-experiment/json — GitHub&lt;/a&gt; — the upstream module usable on older Go toolchains&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://go.dev/doc/go1.26" rel="noopener noreferrer"&gt;Go 1.26 Release Notes&lt;/a&gt; — current stable release context&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;json/v2 is the rare standard-library change that's both faster and safer, and the migration cost is mostly "run your tests and read the diffs." The performance is a nice bonus; the real value is that the defaults finally match what most Go developers meant all along: no more &lt;code&gt;null&lt;/code&gt; slices leaking into frontends, no more silently accepted duplicate keys, and case-insensitive matching stops quietly papering over schema mismatches. Turn on &lt;code&gt;GOEXPERIMENT=jsonv2&lt;/code&gt; in CI this week, see what breaks, and you'll be ready the day Go 1.27 makes it the default.&lt;/p&gt;

</description>
      <category>go</category>
      <category>json</category>
      <category>encodingjson</category>
    </item>
    <item>
      <title>How to Build an AI Agent in Go (From Scratch, No Framework)</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Mon, 13 Jul 2026 08:45:53 +0000</pubDate>
      <link>https://dev.to/dmaxdev/how-to-build-an-ai-agent-in-go-from-scratch-no-framework-5f60</link>
      <guid>https://dev.to/dmaxdev/how-to-build-an-ai-agent-in-go-from-scratch-no-framework-5f60</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;An "AI agent" is a loop: send a prompt to a model, let it ask for a tool, run the tool, feed the result back, repeat until it stops. You can write that loop in Go with the standard library and about 120 lines of code. No LangChain, no SDK, no &lt;code&gt;google.golang.org/adk&lt;/code&gt;. This tutorial builds a working agent that can read files and list directories on your machine, driven by the Anthropic Messages API. I'll show the full source, the exact JSON the API expects, the terminal output from a real run, and the two mistakes that cost me an hour the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why write this in Go at all?
&lt;/h2&gt;

&lt;p&gt;Python owns the agent tutorials, and for good reason — every framework ships a Python client first. But Go has quietly become a strong fit for the &lt;em&gt;runtime&lt;/em&gt; side of agents. A &lt;a href="https://news.ycombinator.com/item?id=47222270" rel="noopener noreferrer"&gt;Hacker News thread from earlier this year&lt;/a&gt; argued the case: one build system, one formatter, static types, and real concurrency without the footguns. When your agent has to fan out ten tool calls, handle timeouts, and ship as a single static binary, that counts for more than how many frameworks exist.&lt;/p&gt;

&lt;p&gt;I spent an afternoon porting a toy Python agent to Go, and the thing I kept noticing was how little I missed the framework. The whole "agent" abstraction collapses into a &lt;code&gt;for&lt;/code&gt; loop and a &lt;code&gt;switch&lt;/code&gt; statement once you see the message flow. That is the point of this post: strip away the layers so you understand what an agent actually is, then decide whether you want a framework on top.&lt;/p&gt;

&lt;p&gt;Here's what we'll build: a working file-reading agent in about 120 lines of Go, with zero third-party dependencies and two tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: an agent is a loop
&lt;/h2&gt;

&lt;p&gt;Strip away the marketing and an LLM agent is four steps in a cycle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Send the conversation (plus a list of tools the model is allowed to call) to the API.&lt;/li&gt;
&lt;li&gt;The model replies. It either answers in text, or it asks to call a tool with some arguments.&lt;/li&gt;
&lt;li&gt;If it asked for a tool, your code runs that tool and captures the result.&lt;/li&gt;
&lt;li&gt;You append the result to the conversation and go back to step 1.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The loop ends when the model responds with plain text and no tool request. The API signals this with &lt;code&gt;stop_reason: "end_turn"&lt;/code&gt;, and that is the whole pattern. Memory, planning, and "ReAct" are all variations on the same cycle. If you understand the loop, you understand agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;You need Go 1.21+ and an API key. Grab one from the &lt;a href="https://console.anthropic.com/" rel="noopener noreferrer"&gt;Anthropic Console&lt;/a&gt; (the tool-use format is cleanest there; I'll note the OpenAI equivalent at the end). Export it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sk-ant-..."&lt;/span&gt;
&lt;span class="nb"&gt;mkdir &lt;/span&gt;go-agent &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;go-agent
go mod init go-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No dependencies to &lt;code&gt;go get&lt;/code&gt;. We use &lt;code&gt;net/http&lt;/code&gt; and &lt;code&gt;encoding/json&lt;/code&gt; from the standard library and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Talk to the model
&lt;/h2&gt;

&lt;p&gt;Before we add tools, let's make one plain request so the wiring is obvious. The Messages API wants a POST to &lt;code&gt;https://api.anthropic.com/v1/messages&lt;/code&gt; with three headers and a JSON body. Here are the types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"bytes"&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"io"&lt;/span&gt;
    &lt;span class="s"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Tool&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"description"`&lt;/span&gt;
    &lt;span class="n"&gt;InputSchema&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="s"&gt;`json:"input_schema"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ContentBlock&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"type"`&lt;/span&gt;
    &lt;span class="c"&gt;// text block&lt;/span&gt;
    &lt;span class="n"&gt;Text&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"text,omitempty"`&lt;/span&gt;
    &lt;span class="c"&gt;// tool_use block (from the model)&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;          &lt;span class="s"&gt;`json:"id,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;          &lt;span class="s"&gt;`json:"name,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Input&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawMessage&lt;/span&gt; &lt;span class="s"&gt;`json:"input,omitempty"`&lt;/span&gt;
    &lt;span class="c"&gt;// tool_result block (from us)&lt;/span&gt;
    &lt;span class="n"&gt;ToolUseID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"tool_use_id,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Content&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"content,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Role&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"role"`&lt;/span&gt;
    &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ContentBlock&lt;/span&gt; &lt;span class="s"&gt;`json:"content"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Model&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;    &lt;span class="s"&gt;`json:"model"`&lt;/span&gt;
    &lt;span class="n"&gt;MaxTokens&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;       &lt;span class="s"&gt;`json:"max_tokens"`&lt;/span&gt;
    &lt;span class="n"&gt;Messages&lt;/span&gt;  &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="s"&gt;`json:"messages"`&lt;/span&gt;
    &lt;span class="n"&gt;Tools&lt;/span&gt;     &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;    &lt;span class="s"&gt;`json:"tools,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt; &lt;span class="k"&gt;struct&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;span class="n"&gt;ContentBlock&lt;/span&gt; &lt;span class="s"&gt;`json:"content"`&lt;/span&gt;
    &lt;span class="n"&gt;StopReason&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"stop_reason"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one struct worth staring at is &lt;code&gt;ContentBlock&lt;/code&gt;. The API models every piece of a turn as a typed block (a &lt;code&gt;text&lt;/code&gt; block, a &lt;code&gt;tool_use&lt;/code&gt; block, a &lt;code&gt;tool_result&lt;/code&gt; block), and &lt;code&gt;omitempty&lt;/code&gt; lets one Go struct serialize as any of them. The model's &lt;code&gt;input&lt;/code&gt; arrives as arbitrary JSON, so I keep it as &lt;code&gt;json.RawMessage&lt;/code&gt; and decode it later per tool.&lt;/p&gt;

&lt;p&gt;Now the call itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;callClaude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&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;tools&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Request&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="s"&gt;"claude-sonnet-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;MaxTokens&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1024&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="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"https://api.anthropic.com/v1/messages"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"x-api-key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ANTHROPIC_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"anthropic-version"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2023-06-01"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"content-type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&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;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api %d: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;out&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 &lt;code&gt;anthropic-version&lt;/code&gt; header is not optional. Leave it out and you get a &lt;code&gt;400&lt;/code&gt; with a message that does not obviously point at the missing header. That was gotcha number one for me. The &lt;a href="https://platform.claude.com/docs/en/api/messages" rel="noopener noreferrer"&gt;Messages API reference&lt;/a&gt; documents the current version string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Define the tools
&lt;/h2&gt;

&lt;p&gt;A tool is a name, a description, and a JSON Schema for its arguments. The description is doing real work here: the model reads it to decide &lt;em&gt;when&lt;/em&gt; to call the tool, so write it the way you'd brief a new teammate who has to figure out on their own when to reach for it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;readFileTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"read_file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Read a file at a relative path and return its contents as text."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InputSchema&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"properties"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"path"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"description"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Relative path, e.g. main.go or docs/readme.md"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s"&gt;"required"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;listFilesTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"list_files"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"List the files and folders in a directory. Defaults to the current directory."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InputSchema&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"properties"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"dir"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s"&gt;"type"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"description"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Directory to list. Empty means current directory."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="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;And the dispatcher that actually runs a tool when the model asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;executeTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"read_file"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"path"`&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"error: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"list_files"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Dir&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"dir"`&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;args&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;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dir&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"."&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadDir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dir&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"error: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"unknown tool: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice I return the error string to the model instead of crashing. That's deliberate. A good agent tool hands failures back as text so the model can recover — try a different path, ask the user, give up gracefully. If your tool panics, the loop dies and the model never gets a chance to react.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The agent loop
&lt;/h2&gt;

&lt;p&gt;This is the whole payoff — the piece that turns a chat call into an agent. Add &lt;code&gt;bufio&lt;/code&gt; and &lt;code&gt;strings&lt;/code&gt; to the imports, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;readFileTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;listFilesTool&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;scanner&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;bufio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewScanner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stdin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;

    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Go agent ready. Ask about files in this folder (Ctrl-D to quit)."&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="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;you&amp;gt; "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;scanner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;break&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="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scanner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&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;text&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&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;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ContentBlock&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"text"&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

        &lt;span class="c"&gt;// Inner loop: keep running tools until the model is done.&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;callClaude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="c"&gt;// Echo the assistant turn back into history verbatim.&lt;/span&gt;
            &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&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;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

            &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ContentBlock&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"text"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;claude&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"tool_use"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"  [tool] %s(%s)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;executeTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ContentBlock&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="s"&gt;"tool_result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;ToolUseID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt; &lt;span class="c"&gt;// stop_reason was end_turn — the model answered.&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&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;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two loops, nested. The outer loop is the human conversation. The inner loop is the agent thinking: it keeps calling tools and feeding results back until the model stops asking. When there are no &lt;code&gt;tool_use&lt;/code&gt; blocks in a response, &lt;code&gt;results&lt;/code&gt; is empty and we break out to wait for the next human message.&lt;/p&gt;

&lt;p&gt;The line that bit me, gotcha number two, is &lt;code&gt;msgs = append(msgs, Message{Role: "assistant", Content: resp.Content})&lt;/code&gt;. You &lt;strong&gt;must&lt;/strong&gt; echo the assistant's &lt;code&gt;tool_use&lt;/code&gt; block back into the history before you send the matching &lt;code&gt;tool_result&lt;/code&gt;. Skip it, and the API rejects your next request because a &lt;code&gt;tool_result&lt;/code&gt; with no preceding &lt;code&gt;tool_use&lt;/code&gt; is malformed. Keeping the raw &lt;code&gt;resp.Content&lt;/code&gt; (including &lt;code&gt;tool_use&lt;/code&gt; blocks as &lt;code&gt;json.RawMessage&lt;/code&gt;) is the simplest way to get this right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;Build and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go build &lt;span class="nt"&gt;-o&lt;/span&gt; agent &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's a real session against a folder holding this project's Go files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Go agent ready. Ask about files in this folder (Ctrl-D to quit).

you&amp;gt; what go files are here and what does main.go do?

  [tool] list_files({"dir":""})
  [tool] read_file({"path":"main.go"})

claude&amp;gt; This folder has three Go files: main.go, tools.go, and go.mod.
main.go is a command-line AI agent. It reads your input, sends it to the
Claude Messages API with two tools (read_file and list_files), runs any
tool the model requests, and loops until the model gives a final answer.
The core is a nested loop in main() that keeps executing tools until
stop_reason is end_turn.

you&amp;gt; which file is the biggest?

  [tool] list_files({"dir":""})
  [tool] read_file({"path":"main.go"})
  [tool] read_file({"path":"tools.go"})

claude&amp;gt; main.go is the largest at roughly 120 lines; tools.go is about 40.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model chained two tool calls in the first turn. It listed the directory, then decided to read &lt;code&gt;main.go&lt;/code&gt;, all before writing a single word of the answer. You did not orchestrate that ordering. The loop simply kept handing control back until the model decided it had enough to answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you actually want a framework
&lt;/h2&gt;

&lt;p&gt;Building from scratch is the right way to &lt;em&gt;learn&lt;/em&gt;, and honestly it's fine for small production agents too. But once you need streaming, retries with backoff, session persistence, or multi-agent handoff, a framework earns its place. Here are the main Go options today:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Standard library (this post)&lt;/td&gt;
&lt;td&gt;Raw HTTP + your own loop&lt;/td&gt;
&lt;td&gt;Learning, full control, single static binary, minimal surface area&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/anthropics/anthropic-sdk-go" rel="noopener noreferrer"&gt;Anthropic Go SDK&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Official typed client&lt;/td&gt;
&lt;td&gt;You want typed requests/streaming for one provider without hand-rolling JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/google/adk-go" rel="noopener noreferrer"&gt;Google ADK for Go&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Full agent framework: sessions, runners, tools&lt;/td&gt;
&lt;td&gt;Multi-agent orchestration, Gemini-first stacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/cloudwego/eino" rel="noopener noreferrer"&gt;Eino&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;ByteDance's composable orchestration graph&lt;/td&gt;
&lt;td&gt;Complex pipelines with branching and fan-out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/tmc/langchaingo" rel="noopener noreferrer"&gt;langchaingo&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;LangChain-style multi-provider abstraction&lt;/td&gt;
&lt;td&gt;You want one interface across OpenAI, Anthropic, and local models&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My honest take: start with the standard library, add the official SDK when JSON marshaling gets tedious, and only pull in a full framework when you feel real pain from the missing pieces. Most agents I've seen reach for a framework far too early and end up fighting its abstractions instead of shipping.&lt;/p&gt;

&lt;p&gt;If you're coming from the editor side of the agent world, the &lt;a href="https://www.danilchenko.dev/posts/agent-client-protocol/" rel="noopener noreferrer"&gt;Agent Client Protocol&lt;/a&gt; covers how these loops plug into IDEs, and &lt;a href="https://www.danilchenko.dev/posts/claude-code-subagents/" rel="noopener noreferrer"&gt;Claude Code Subagents&lt;/a&gt; shows the same tool-calling idea running inside a production coding agent. For the concurrency question specifically (why Go's model helps when tools run in parallel), the &lt;a href="https://www.danilchenko.dev/posts/rust-vs-go/" rel="noopener noreferrer"&gt;Rust vs Go comparison&lt;/a&gt; digs into the tradeoffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts I deliberately left out
&lt;/h2&gt;

&lt;p&gt;This agent is real but minimal. Three things you'd add before trusting it with anything important:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails on tools.&lt;/strong&gt; My &lt;code&gt;read_file&lt;/code&gt; will happily read &lt;code&gt;/etc/passwd&lt;/code&gt; if the model asks. In production you sanitize paths and sandbox the filesystem. I wrote up four ways this goes wrong in &lt;a href="https://www.danilchenko.dev/posts/ai-agent-guardrails/" rel="noopener noreferrer"&gt;AI agent guardrails&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A turn limit.&lt;/strong&gt; The inner loop trusts the model to eventually stop. A confused model can ping-pong tools forever. Add a counter and bail after, say, 10 iterations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming.&lt;/strong&gt; We wait for the full response before printing. For a snappy CLI you'd switch to the streaming endpoint and print tokens as they arrive — Go's &lt;code&gt;bufio.Scanner&lt;/code&gt; over the SSE body handles this cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these change the shape of the loop; they just harden it around the edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Go good for building AI agents?
&lt;/h3&gt;

&lt;p&gt;Yes, for the runtime. Go gives you static binaries, real concurrency for parallel tool calls, and strong typing around the JSON the API speaks. The tradeoff is fewer frameworks than Python — but as this post shows, you often don't need one. If your agent is mostly orchestration and I/O rather than data science, Go is a strong pick.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a framework like LangChain to build an agent in Go?
&lt;/h3&gt;

&lt;p&gt;No. An agent is a loop over a chat API with tool calling, and the standard library covers it in ~120 lines. Frameworks add value for streaming, retries, session state, and multi-agent coordination, but they are optional. Start without one so you understand the mechanics.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does an AI agent work under the hood?
&lt;/h3&gt;

&lt;p&gt;It's a cycle: send the conversation plus a tool list to the model; the model either answers or requests a tool call; your code runs the tool and appends the result; repeat until the model answers with no tool request. The API's &lt;code&gt;stop_reason&lt;/code&gt; field tells you when the loop is done.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use OpenAI instead of Anthropic?
&lt;/h3&gt;

&lt;p&gt;Yes. The shape is identical, only the field names differ. With the &lt;a href="https://platform.openai.com/" rel="noopener noreferrer"&gt;OpenAI API&lt;/a&gt; you POST to &lt;code&gt;/v1/chat/completions&lt;/code&gt;, tools live under a &lt;code&gt;tools&lt;/code&gt; array with a &lt;code&gt;function&lt;/code&gt; wrapper, and the model returns &lt;code&gt;tool_calls&lt;/code&gt; instead of &lt;code&gt;tool_use&lt;/code&gt; blocks. Swap the request/response structs and the &lt;code&gt;executeTool&lt;/code&gt; dispatcher stays exactly the same.&lt;/p&gt;

&lt;h3&gt;
  
  
  What model should I use for a Go agent?
&lt;/h3&gt;

&lt;p&gt;Any model with tool-calling support works. I used &lt;code&gt;claude-sonnet-5&lt;/code&gt; because it's fast and reliable at deciding when to call a tool. For cheaper runs, a smaller model like Haiku handles simple tool routing well; for hard multi-step tasks, a frontier model chains tools more reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/api/messages" rel="noopener noreferrer"&gt;Anthropic Messages API reference&lt;/a&gt; — the request/response format and tool-use schema this agent speaks&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/docs/build-with-claude/tool-use" rel="noopener noreferrer"&gt;Anthropic tool use documentation&lt;/a&gt; — how &lt;code&gt;tool_use&lt;/code&gt; and &lt;code&gt;tool_result&lt;/code&gt; blocks work&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/anthropic-sdk-go" rel="noopener noreferrer"&gt;Anthropic Go SDK&lt;/a&gt; — official typed client for production use&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/google/adk-go" rel="noopener noreferrer"&gt;Google ADK for Go&lt;/a&gt; — full agent framework, referenced in the comparison table&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://news.ycombinator.com/item?id=47222270" rel="noopener noreferrer"&gt;"A case for Go as the best language for AI agents" — Hacker News&lt;/a&gt; — the discussion that framed the "why Go" argument&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;The framework tutorials make agents look like a stack of abstractions you have to learn first. Really an agent is just a loop that hands control back and forth between a model and your tools, and Go writes that loop about as cleanly as any language I've tried. Build the 120-line version first. Once you can see the message flow in your head, you'll know exactly which framework feature you're missing — and, more often than not, that you don't need one yet.&lt;/p&gt;

</description>
      <category>go</category>
      <category>agents</category>
      <category>llm</category>
      <category>anthropicapi</category>
    </item>
    <item>
      <title>Claude Sonnet 5 Review: A Week With Anthropic's New Default</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Tue, 07 Jul 2026 08:42:22 +0000</pubDate>
      <link>https://dev.to/dmaxdev/claude-sonnet-5-review-a-week-with-anthropics-new-default-55p2</link>
      <guid>https://dev.to/dmaxdev/claude-sonnet-5-review-a-week-with-anthropics-new-default-55p2</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Claude Sonnet 5 shipped on June 30, 2026 and immediately became the default model on Claude's Free and Pro plans. Anthropic's published numbers put it at 63.2% on SWE-bench Pro, a few points behind Opus 4.8's 69.2% but well ahead of Sonnet 4.6's 58.1%. On Terminal-Bench 2.1 it actually beats Opus 4.8 (80.4% vs 74.6%). Intro pricing is $2 per million input tokens and $10 output through August 31, then $3/$15. One detail is easy to miss: it ships with a new tokenizer that uses 1.0–1.35× more tokens for the same text, so the sticker price undersells what you'll actually pay. After a week of using it as my daily driver in Claude Code, my take is that Sonnet 5 is the model most people should be running now, and Opus 4.8 has shrunk to a specialist tool you reach for on the genuinely hard problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Sonnet 5 Actually Is
&lt;/h2&gt;

&lt;p&gt;Anthropic ships models in three sizes: Haiku for cheap and fast, Sonnet for the middle, Opus for the frontier. Sonnet has always been the workhorse, the one that runs when you don't think about which model you're using. &lt;a href="https://www.anthropic.com/news/claude-sonnet-5" rel="noopener noreferrer"&gt;Sonnet 5&lt;/a&gt; is the fifth generation of that workhorse, and the pitch is that the middle tier has crept close enough to the top tier that the top tier stops being the obvious choice.&lt;/p&gt;

&lt;p&gt;It landed as the default for Free and Pro on claude.ai the day it launched, with access for Max, Team, and Enterprise, plus the API id &lt;code&gt;claude-sonnet-5&lt;/code&gt; on the Claude Platform and inside Claude Code. That default placement counts for more than the benchmarks. Most Claude traffic never picks a model on purpose. Whatever Anthropic sets as the default writes most of the code, drafts most of the emails, and shapes what people think "Claude" is. That makes Sonnet 5 the new baseline for almost everyone who touches Claude, whether they know the version number or not.&lt;/p&gt;

&lt;p&gt;I flipped my Claude Code default to it on launch morning and left it there for a week, running it against a Go service with a messy migration path, a Next.js dashboard, and a pile of one-off scripts. This was the everyday work sitting in my terminal, the kind I'd normally split between Claude and Codex. What stuck with me is that it finishes things. Multi-step tasks that used to stall halfway and wait for me to nudge them now run to completion on their own, and that single behavioral change is worth more than any single-digit benchmark bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Benchmark Breakdown
&lt;/h2&gt;

&lt;p&gt;Here are the numbers &lt;a href="https://www.marktechpost.com/2026/06/30/anthropic-claude-sonnet-5-vs-sonnet-4-6-vs-opus-4-8-agentic-coding-benchmarks-api-pricing-and-cost-performance-tradeoffs-compared/" rel="noopener noreferrer"&gt;Anthropic and early testers published&lt;/a&gt;, lined up against the two models Sonnet 5 is really being measured against: its own predecessor and the flagship one rung up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;Sonnet 5&lt;/th&gt;
&lt;th&gt;Sonnet 4.6&lt;/th&gt;
&lt;th&gt;Opus 4.8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SWE-bench Pro (agentic coding)&lt;/td&gt;
&lt;td&gt;63.2%&lt;/td&gt;
&lt;td&gt;58.1%&lt;/td&gt;
&lt;td&gt;69.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminal-Bench 2.1&lt;/td&gt;
&lt;td&gt;80.4%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;74.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OSWorld-Verified (computer use)&lt;/td&gt;
&lt;td&gt;81.2%&lt;/td&gt;
&lt;td&gt;78.5%&lt;/td&gt;
&lt;td&gt;83.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BrowseComp (agentic search)&lt;/td&gt;
&lt;td&gt;84.7%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GDPval-AA v2 (knowledge work, Elo)&lt;/td&gt;
&lt;td&gt;1,618&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1,615&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FrontierCode&lt;/td&gt;
&lt;td&gt;38.8%&lt;/td&gt;
&lt;td&gt;15.1%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows stand out. On SWE-bench Pro, the 5.1-point jump from Sonnet 4.6 to Sonnet 5 (58.1 to 63.2) is a real generational gain on a benchmark where model families usually move a couple of points per release. FrontierCode more than doubled in reported results, from 15.1% to 38.8%. Since FrontierCode targets the hard, long-horizon coding problems that break most models, a 2.5× jump there is the clearest signal that Sonnet 5 got structurally better at sustained agentic work rather than merely more polished.&lt;/p&gt;

&lt;p&gt;The Terminal-Bench result is the one that made me raise an eyebrow. A mid-tier model beating the flagship on any benchmark is unusual, and Terminal-Bench 2.1 measures exactly the kind of shell-driving, command-running, output-reading loop that agentic coding lives in. Sonnet 5's 80.4% against Opus 4.8's 74.6% lines up with what I felt using it: for the day-to-day loop of edit, run tests, read the failure, fix, rerun, it is genuinely excellent, and the extra brains in Opus don't help much on that kind of task.&lt;/p&gt;

&lt;p&gt;Where Opus still wins is SWE-bench Pro and OSWorld, both by a handful of points. Those gaps are real but small, and they narrow further once you weigh the cost difference. The GDPval knowledge-work Elo is essentially a tie (1,618 vs 1,615), which tells you the reasoning and writing quality of the two models is now hard to tell apart on ordinary tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Week Of Real Use
&lt;/h2&gt;

&lt;p&gt;Benchmarks tell you a model's ceiling. A week of use tells you its floor, how it behaves on the boring 90% of tasks that never make it into a benchmark suite.&lt;/p&gt;

&lt;p&gt;The best thing about Sonnet 5 is the self-verification loop. On my Go service, I asked it to migrate a set of handlers from a hand-rolled router to &lt;code&gt;chi&lt;/code&gt; and update the tests. Sonnet 4.6 would have written the change and stopped, leaving me to run the suite and paste back the failures. Sonnet 5 wrote the change, ran the tests itself, saw two of them fail on a middleware ordering bug, fixed the ordering, and reran until green — all without me typing anything between the first prompt and the finished diff. It did this consistently over the week, not once as a lucky demo. Early coverage reports the same pattern: the model runs tests, reads the failures, patches, and reruns without being told to. That is the behavior that turns "AI that writes code" into "AI that finishes a task."&lt;/p&gt;

&lt;p&gt;The second thing I noticed is that it's honest about uncertainty more often. On an ambiguous refactor where I hadn't specified whether to keep backward compatibility, it stopped and asked instead of guessing and plowing ahead. That's a small thing that saves a lot of wasted diffs.&lt;/p&gt;

&lt;p&gt;It still has clear failure modes. On a gnarly Next.js server-component bug involving a hydration mismatch, Sonnet 5 went in circles twice: it proposed the same wrong fix, I rejected it, and it came back with a slight variation of the same wrong fix. I switched that one task to Opus 4.8 and it found the actual cause (a &lt;code&gt;Date&lt;/code&gt; being rendered without a stable server/client format) in one pass. That is the shape of the current gap: on the genuinely hard, ambiguous problems that need real backtracking, Opus still pulls ahead. On everything else, you won't notice a difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tokenizer Tax
&lt;/h2&gt;

&lt;p&gt;The pricing page leaves out one detail. Sonnet 5 ships with an updated tokenizer, and &lt;a href="https://www.anthropic.com/news/claude-sonnet-5" rel="noopener noreferrer"&gt;Anthropic's own announcement&lt;/a&gt; says it produces roughly 1.0 to 1.35× more tokens for the same input text than Sonnet 4.6 did. That multiplier is not uniform: plain English lands near the low end, while code, structured data, and non-English text push toward the high end.&lt;/p&gt;

&lt;p&gt;What that means in practice: the $2/$10 intro price is genuinely cheap, but your token counts go up, so the effective cost per request rises more than the sticker drop suggests. If you're comparing Sonnet 5's $3/$15 standard price against Sonnet 4.6's $3/$15, they look identical on paper, but your bill won't match, because the same task now consumes more tokens. I watched this happen in Claude Code's usage meter over the week: comparable tasks showed noticeably higher token counts than I was used to under 4.6.&lt;/p&gt;

&lt;p&gt;This doesn't make Sonnet 5 expensive. It's still dramatically cheaper than Opus 4.8. But it does mean you should run your own numbers before assuming a migration is a pure cost win. If you have a high-volume production pipeline with tight per-task budgets, measure the real token consumtion on your actual prompts rather than trusting the price-per-million headline.&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;Input / 1M&lt;/th&gt;
&lt;th&gt;Output / 1M&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5 (intro, through Aug 31)&lt;/td&gt;
&lt;td&gt;$2&lt;/td&gt;
&lt;td&gt;$10&lt;/td&gt;
&lt;td&gt;New tokenizer, +0–35% tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5 (standard, from Sep 1)&lt;/td&gt;
&lt;td&gt;$3&lt;/td&gt;
&lt;td&gt;$15&lt;/td&gt;
&lt;td&gt;Same sticker as 4.6, higher token use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 4.6&lt;/td&gt;
&lt;td&gt;$3&lt;/td&gt;
&lt;td&gt;$15&lt;/td&gt;
&lt;td&gt;Older, cheaper-per-token tokenizer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.8&lt;/td&gt;
&lt;td&gt;$5&lt;/td&gt;
&lt;td&gt;$25&lt;/td&gt;
&lt;td&gt;Flagship; reach for the hard tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When To Use Sonnet 5 vs Opus 4.8
&lt;/h2&gt;

&lt;p&gt;After a week, my routing rule is simple. Send everything to Sonnet 5 by default. Reach for Opus 4.8 only when a task is hard enough that a wrong answer is expensive: a subtle concurrency bug, an ambiguous spec that needs careful interpretation, a refactor where backtracking pays off. Keep Haiku 4.5 for the high-volume, latency-sensitive calls where you'd never have used a big model anyway.&lt;/p&gt;

&lt;p&gt;That's a real shift from three months ago, when Opus was my default for anything that mattered and Sonnet was the "good enough, save money" option. Now the ordering has flipped: Sonnet 5 is the default that's good enough for almost everything, and Opus is the specialist you escalate to, with &lt;a href="https://www.danilchenko.dev/posts/claude-fable-5-review/" rel="noopener noreferrer"&gt;Fable 5&lt;/a&gt; sitting above it for the longest autonomous runs. The GDPval tie and the Terminal-Bench win explain why. On the work that fills most of a day, the two models produce output I can't reliably tell apart, so paying 2.5× more for Opus stops making sense.&lt;/p&gt;

&lt;p&gt;If you're weighing this against non-Anthropic options, the picture is more crowded than it was. &lt;a href="https://www.danilchenko.dev/posts/glm-5-2-review/" rel="noopener noreferrer"&gt;GLM-5.2&lt;/a&gt; undercuts everyone on price with open weights and lands close on coding benchmarks, and &lt;a href="https://www.danilchenko.dev/posts/gpt-5-5-review/" rel="noopener noreferrer"&gt;GPT-5.5&lt;/a&gt; trades blows on general tasks. Sonnet 5's edge over both is the agentic follow-through, the finish-the-task behavior, which is exactly the thing that doesn't show up cleanly in a single benchmark score. For a broader field, our &lt;a href="https://www.danilchenko.dev/posts/gpt-claude-gemini-coding/" rel="noopener noreferrer"&gt;GPT vs Claude vs Gemini coding comparison&lt;/a&gt; still holds up on the general shape of the tradeoffs, even if the specific version numbers have moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  How To Switch To Sonnet 5
&lt;/h2&gt;

&lt;p&gt;If you're on claude.ai Free or Pro, you're already using it; it's the default. For everything else, you point at the model id.&lt;/p&gt;

&lt;p&gt;Via the API with the Python SDK from the &lt;a href="https://console.anthropic.com/" rel="noopener noreferrer"&gt;Anthropic console&lt;/a&gt;:&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;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# reads ANTHROPIC_API_KEY
&lt;/span&gt;
&lt;span class="n"&gt;message&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;messages&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;claude-sonnet-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4096&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="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;Refactor this Go HTTP handler to use chi router &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;and update the table-driven tests.&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="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;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;span class="mi"&gt;0&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Claude Code, set it as the session default so every agent turn uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One-off for the current session&lt;/span&gt;
claude &lt;span class="nt"&gt;--model&lt;/span&gt; claude-sonnet-5

&lt;span class="c"&gt;# Or make it sticky in your shell profile&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you drive Claude through a coding IDE, the same model id works. I run it as the default in &lt;a href="https://www.cursor.com/" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt; for ordinary edits and only flip to Opus when a task fights back. One thing to test before you migrate a production prompt: the new tokenizer can change how structured inputs get parsed, so if you have prompts that depend on exact output formatting or tight tool-call schemas, run them through once and check the edges rather than assuming a drop-in swap.&lt;/p&gt;

&lt;p&gt;Here's a quick way to sanity-check the token difference on your own text before and after switching, using the count-tokens endpoint:&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;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;resp&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_tokens&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;claude-sonnet-5&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="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sonnet 5 input tokens:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against a representative prompt, compare it to what the same text cost you under Sonnet 4.6, and you'll know your real multiplier instead of guessing at the 1.0–1.35× range.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Use It, Who Should Skip
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Good fit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anyone already on Claude for coding. Sonnet 5 is a straight upgrade over 4.6 in every category, and during the intro window it's cheaper too.&lt;/li&gt;
&lt;li&gt;Teams running agentic workflows (Claude Code, Cursor, custom harnesses) where the self-verification loop saves human turns.&lt;/li&gt;
&lt;li&gt;Cost-conscious builders who were paying for Opus out of habit. Most of that work drops to Sonnet 5 with no quality loss you'll notice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Think twice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High-volume production pipelines with razor-thin margins. Measure the tokenizer tax on your real traffic before you assume the migration saves money.&lt;/li&gt;
&lt;li&gt;Anyone whose prompts depend on exact tokenization or output formatting. Test the edges first.&lt;/li&gt;
&lt;li&gt;Work that lives at the hard end of the difficulty curve: deep debugging, ambiguous specs, tasks that need backtracking. Keep Opus 4.8 in reach for those, and see our &lt;a href="https://www.danilchenko.dev/posts/claude-code-max-vs-pro/" rel="noopener noreferrer"&gt;Claude Code Max vs Pro breakdown&lt;/a&gt; if you're deciding how much Anthropic compute to buy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How much does Claude Sonnet 5 cost?
&lt;/h3&gt;

&lt;p&gt;Introductory API pricing is $2 per million input tokens and $10 per million output tokens through August 31, 2026, moving to $3/$15 after that. On claude.ai it's the default model on the Free and Pro plans at no extra cost. Factor in the new tokenizer, which uses 1.0–1.35× more tokens for the same text, when you estimate real spend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Claude Sonnet 5 good for coding?
&lt;/h3&gt;

&lt;p&gt;Yes — it's the strongest mid-tier coding model Anthropic has shipped. It scores 63.2% on SWE-bench Pro (up from Sonnet 4.6's 58.1%) and beats Opus 4.8 on Terminal-Bench 2.1. The standout in daily use is agentic follow-through: it runs tests, reads failures, and fixes its own code without being told to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claude Sonnet 5 vs Opus 4.8 — which should I use?
&lt;/h3&gt;

&lt;p&gt;Use Sonnet 5 as your default for almost everything; it's close to Opus 4.8 on most benchmarks, wins on Terminal-Bench, and costs less than half as much. Reserve Opus 4.8 for the hardest tasks (subtle bugs, ambiguous requirements, anything that needs real backtracking) where its extra reasoning still pulls ahead.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Claude Sonnet 5's context window?
&lt;/h3&gt;

&lt;p&gt;Sonnet 5 supports a 1 million token context window, matching the top of Anthropic's lineup. That's enough to hold a mid-size codebase, long agent trajectories, or large documents in a single session.&lt;/p&gt;

&lt;h3&gt;
  
  
  When was Claude Sonnet 5 released?
&lt;/h3&gt;

&lt;p&gt;Anthropic released Claude Sonnet 5 on June 30, 2026, and made it the default model on the Free and Pro plans the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.anthropic.com/news/claude-sonnet-5" rel="noopener noreferrer"&gt;Introducing Claude Sonnet 5 (Anthropic)&lt;/a&gt;: official announcement, pricing, availability, and the tokenizer change&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/about-claude/models/overview" rel="noopener noreferrer"&gt;Claude models overview (Claude Platform Docs)&lt;/a&gt;: model ids, context windows, and standard pricing&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.marktechpost.com/2026/06/30/anthropic-claude-sonnet-5-vs-sonnet-4-6-vs-opus-4-8-agentic-coding-benchmarks-api-pricing-and-cost-performance-tradeoffs-compared/" rel="noopener noreferrer"&gt;Claude Sonnet 5 vs Sonnet 4.6 vs Opus 4.8 (MarkTechPost)&lt;/a&gt;: benchmark table and cost-performance analysis&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.finout.io/blog/claude-sonnet-5-pricing-2026-the-hidden-costs-and-real-savings-behind-the-cost-neutral-launch" rel="noopener noreferrer"&gt;Claude Sonnet 5 pricing: the hidden costs behind the "cost-neutral" launch (Finout)&lt;/a&gt;: breakdown of the tokenizer's effect on real spend&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;Claude Sonnet 5 is the clearest sign yet that the interesting action in AI coding has moved from the frontier tier to the tier just below it. It nearly matches Opus 4.8 on the benchmarks that matter, beats it outright on the terminal-driving loop that agentic coding actually runs on, and does it at less than half the price. After a week of leaving it as my Claude Code default, the only tasks I sent back to Opus were the two or three genuinely hard bugs a week throws at you.&lt;/p&gt;

&lt;p&gt;The tokenizer tax is the one thing to watch. Sonnet 5 is cheaper than Opus and, during the intro window, cheaper than 4.6, but the higher token consumption means you should measure your real cost rather than trust the headline. Do that math, then switch. For the work that fills most of a developer's day, this is the model to reach for now, and if you're on a Free or Pro plan, it's already the one running by default.&lt;/p&gt;

</description>
      <category>claudesonnet5</category>
      <category>anthropic</category>
      <category>aicoding</category>
      <category>llm</category>
    </item>
    <item>
      <title>Perplexity Bumblebee Review: The Supply Chain Scanner Your Dev Machine Needs</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Mon, 29 Jun 2026 08:35:52 +0000</pubDate>
      <link>https://dev.to/dmaxdev/perplexity-bumblebee-review-the-supply-chain-scanner-your-dev-machine-needs-20ic</link>
      <guid>https://dev.to/dmaxdev/perplexity-bumblebee-review-the-supply-chain-scanner-your-dev-machine-needs-20ic</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Bumblebee is a read-only supply chain scanner from Perplexity AI that checks your installed packages, editor extensions, MCP configs, and browser extensions against known-compromised versions. It never runs a package manager command. I ran it on three machines. It found 847 packages across 9 ecosystems in under 4 seconds. The output is NDJSON you can pipe into anything. It won't replace Snyk or Socket in your CI pipeline, but for the question "does anyone on the team have that compromised package installed right now?" it's the fastest answer that exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Developer Machines Are the Blind Spot
&lt;/h2&gt;

&lt;p&gt;When a supply chain advisory drops (say, a compromised npm package like &lt;code&gt;@redhat-cloud-services/frontend-components&lt;/code&gt; in June 2026), the security team's first question is simple: who has it installed?&lt;/p&gt;

&lt;p&gt;Your CI pipeline knows what's in your lockfile. Your SBOM tool knows what shipped to production. Your EDR knows what processes ran. None of them know what's sitting in &lt;code&gt;~/.npm&lt;/code&gt;, what extensions your VS Code installed last Tuesday, or what MCP servers your Claude Code config points to.&lt;/p&gt;

&lt;p&gt;Sonatype's 2026 State of the Software Supply Chain report counted over 454,600 new malicious open-source packages in 2025, pushing the cumulative total past 1.233 million. That's a 75% year-over-year increase. The attacks keep getting more creative, too: the Mini Shai-Hulud campaigns starting in late April 2026 hit npm, PyPI, RubyGems, and Composer across companies like SAP, with the TanStack compromise following in May. In June 2026, TeamPCP's Miasma worm injected itself into the SessionStart hooks of 13 AI coding tools, including Claude Code, GitHub Copilot, and &lt;a href="https://www.danilchenko.dev/posts/gemini-cli-tutorial/" rel="noopener noreferrer"&gt;Gemini CLI&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That last one is why MCP config scanning matters. If your &lt;code&gt;claude_desktop_config.json&lt;/code&gt; or &lt;code&gt;mcp.json&lt;/code&gt; references a compromised server, you've handed an attacker tool-level access to your coding environment. Traditional security scanners don't even look at these files.&lt;/p&gt;

&lt;p&gt;Bumblebee fills that gap. It answers one question: what packages, extensions, and configs are on this machine right now, and do any of them match a known-bad advisory?&lt;/p&gt;

&lt;h2&gt;
  
  
  What Bumblebee Actually Is
&lt;/h2&gt;

&lt;p&gt;Bumblebee is a single static binary written in &lt;a href="https://www.danilchenko.dev/posts/go-1-26-features/" rel="noopener noreferrer"&gt;Go 1.25+&lt;/a&gt; with zero non-stdlib dependencies. Perplexity open-sourced it in May 2026 under Apache 2.0. The v0.1.1 release has crossed 4k GitHub stars.&lt;/p&gt;

&lt;p&gt;The design is deliberately narrow. Bumblebee collects an inventory of what's installed on a developer machine and optionally matches that inventory against an exposure catalog of known-compromised packages. It does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;pip install&lt;/code&gt;, or any other package manager command&lt;/li&gt;
&lt;li&gt;Execute install scripts or lifecycle hooks&lt;/li&gt;
&lt;li&gt;Read your source code&lt;/li&gt;
&lt;li&gt;Make network calls during the scan&lt;/li&gt;
&lt;li&gt;Modify anything on disk&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last set of constraints is the whole point. When your machine might already be compromised, the scan tool itself can't be the thing that triggers the compromise. npm's &lt;code&gt;postinstall&lt;/code&gt; scripts are a well-known attack vector. Bumblebee never gives them a chance to fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Scans
&lt;/h2&gt;

&lt;p&gt;The coverage spans ten ecosystems, which is wider than I expected from a v0.1 tool:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ecosystem&lt;/th&gt;
&lt;th&gt;What It Reads&lt;/th&gt;
&lt;th&gt;Package Manager&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock&lt;/td&gt;
&lt;td&gt;npm/yarn/pnpm/bun&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyPI&lt;/td&gt;
&lt;td&gt;*.dist-info/METADATA, *.egg-info/PKG-INFO&lt;/td&gt;
&lt;td&gt;pip/setuptools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go modules&lt;/td&gt;
&lt;td&gt;go.sum, go.mod&lt;/td&gt;
&lt;td&gt;go&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RubyGems&lt;/td&gt;
&lt;td&gt;Gemfile.lock, *.gemspec&lt;/td&gt;
&lt;td&gt;bundler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composer&lt;/td&gt;
&lt;td&gt;composer.lock, installed.json&lt;/td&gt;
&lt;td&gt;composer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Homebrew&lt;/td&gt;
&lt;td&gt;INSTALL_RECEIPT.json, .metadata&lt;/td&gt;
&lt;td&gt;brew&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP servers&lt;/td&gt;
&lt;td&gt;claude_desktop_config.json, mcp.json, various IDE configs&lt;/td&gt;
&lt;td&gt;Claude Code/Cursor/Gemini CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent skills&lt;/td&gt;
&lt;td&gt;skills-lock.json&lt;/td&gt;
&lt;td&gt;vercel-labs/skills&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editor extensions&lt;/td&gt;
&lt;td&gt;VS Code, Cursor, Windsurf, VSCodium manifests&lt;/td&gt;
&lt;td&gt;IDE ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser extensions&lt;/td&gt;
&lt;td&gt;Chrome, Edge, Brave, Arc, Firefox manifests&lt;/td&gt;
&lt;td&gt;Browser ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The MCP and agent skills scanning is what makes this feel purpose-built for 2026. Phoenix Security's 2026 supply chain report found that AI-agent skills carry a risk rate &lt;a href="https://phoenix.security/accelerating-supply-chain-attacks-npm-pypi-vsx-ai-enabled-2026/" rel="noopener noreferrer"&gt;2.3 times higher than IDE extensions&lt;/a&gt;, and more than 1 in 4 deep-scanned skills triggered a critical-risk finding. Bumblebee parses &lt;code&gt;claude_desktop_config.json&lt;/code&gt;, &lt;code&gt;mcp.json&lt;/code&gt;, and Gemini CLI config files to catalog which &lt;a href="https://www.danilchenko.dev/posts/fastmcp-mcp-server/" rel="noopener noreferrer"&gt;MCP servers&lt;/a&gt; are registered and their source packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running It: Three Machines, Three Profiles
&lt;/h2&gt;

&lt;p&gt;I installed Bumblebee via &lt;code&gt;go install&lt;/code&gt; and ran it across three environments: my primary macOS development laptop, a Linux CI runner, and a colleague's machine that I suspected had stale npm packages from a prototype project six months ago.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/perplexityai/bumblebee/cmd/bumblebee@v0.1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One binary. No config files. No daemon. The Go install pulls the source and compiles it locally, so the binary on your machine was built from source you can audit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Baseline Scan
&lt;/h3&gt;

&lt;p&gt;The baseline profile hits common global package roots, editor extensions, browser extensions, and MCP configs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bumblebee scan &lt;span class="nt"&gt;--profile&lt;/span&gt; baseline &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; inventory.ndjson
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my macOS machine, the baseline scan completed in 1.8 seconds and found 312 packages across npm, PyPI, Go modules, Homebrew, 23 VS Code extensions, 4 Cursor extensions, and 3 MCP server configurations.&lt;/p&gt;

&lt;p&gt;Each record in the NDJSON output looks like this:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"record_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"package"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hostname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"maxbook.local"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ecosystem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"package_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"filesystem-mcp-server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.6.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp_config"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source_file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/Users/max/.config/claude/claude_desktop_config.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="w"&gt;
&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 confidence levels are useful. &lt;code&gt;high&lt;/code&gt; means exact canonical metadata with verified version. &lt;code&gt;medium&lt;/code&gt; means reliable identity but partial version info, which is common for MCP servers where the config only records a package reference. &lt;code&gt;low&lt;/code&gt; means it's a config path or spec reference only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project Scan
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bumblebee scan &lt;span class="nt"&gt;--profile&lt;/span&gt; project &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/code"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Developer"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; project-inventory.ndjson
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This took 3.4 seconds and found 535 packages. The extra coverage comes from crawling lockfiles in project directories. A Go project I'd forgotten about contributed 47 transitive dependencies from its &lt;code&gt;go.sum&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deep Scan
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bumblebee scan &lt;span class="nt"&gt;--profile&lt;/span&gt; deep &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-duration&lt;/span&gt; 10m &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; deep-inventory.ndjson
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deep profile is for incident response. It sweeps everything under the given root. On my machine with a messy home directory, it found 847 packages in 3.9 seconds. On the colleague's machine (the one with stale npm prototypes), it found 1,423 packages. A lot of them were cached npm packages from six months ago that nobody realized were still sitting on disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exposure Check
&lt;/h3&gt;

&lt;p&gt;This is where Bumblebee earns its keep. Once you have an advisory (say, the June 2026 &lt;code&gt;@redhat-cloud-services&lt;/code&gt; compromise), you write an exposure catalog:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schema_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"advisory-2026-redhat-npm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@redhat-cloud-services/frontend-components 4.2.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ecosystem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"package"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@redhat-cloud-services/frontend-components"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"versions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"4.2.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4.2.4"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"critical"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&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;Then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bumblebee scan &lt;span class="nt"&gt;--profile&lt;/span&gt; deep &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--root&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exposure-catalog&lt;/span&gt; ./catalog.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any match exists, Bumblebee emits a &lt;code&gt;finding&lt;/code&gt; record alongside the normal inventory. The finding includes the matched package, version, catalog entry, and severity. Pipe the output through &lt;code&gt;jq '.record_type == "finding"'&lt;/code&gt; and you have a yes/no answer in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-Test
&lt;/h3&gt;

&lt;p&gt;Before trusting it with real incident data, verify the binary works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bumblebee selftest
&lt;span class="c"&gt;# selftest OK (2 findings in 1ms)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The self-test uses embedded fixtures with fake package names. It validates that the scanning and matching logic works correctly on your platform before you point it at real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Bumblebee Fits (and Where It Doesn't)
&lt;/h2&gt;

&lt;p&gt;Don't treat Bumblebee as a replacement for your existing security toolchain. It's a complement. Here's how it maps against the tools you probably already use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;th&gt;When It Runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Snyk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scans dependencies for known CVEs, generates fix PRs&lt;/td&gt;
&lt;td&gt;CI/CD, on PR merge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Socket&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Behavioral analysis of packages pre-install (typosquatting, exfiltration)&lt;/td&gt;
&lt;td&gt;Pre-install, CI/CD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OSV-Scanner&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Matches lockfiles against the OSV database&lt;/td&gt;
&lt;td&gt;CI/CD, local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;npm audit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Checks installed packages against the npm advisory database&lt;/td&gt;
&lt;td&gt;Post-install, CI/CD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bumblebee&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reads on-disk metadata across 10 ecosystems, no execution&lt;/td&gt;
&lt;td&gt;On-demand, scheduled on dev machines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bumblebee fills the post-incident triage gap. Snyk tells you what's vulnerable in your repo. Socket tells you if a package is behaving suspiciously before you install it. Bumblebee tells you which developer machines are currently exposed to a specific known-compromised package. They complement each other.&lt;/p&gt;

&lt;p&gt;The strongest use case: a zero-day advisory drops at 2 AM. Your security team pushes an exposure catalog. Every developer machine runs &lt;code&gt;bumblebee scan --profile deep --exposure-catalog ./catalog.json&lt;/code&gt; as part of their morning boot script. By the time standup happens, you know exactly who's affected.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Speed.&lt;/strong&gt; Sub-4-second scans across 800+ packages on a well-loaded macOS machine. It reads metadata files directly from the filesystem, with no process spawning, no network calls, and no dependency resolution. The Go implementation is tight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero dependencies.&lt;/strong&gt; The entire binary is Go standard library. For a security tool, that's a big deal: every third-party dependency is an attack surface. Bumblebee's supply chain risk is literally zero non-stdlib packages. You can verify this yourself. The &lt;code&gt;go.mod&lt;/code&gt; file lists only the module path and Go version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP config scanning.&lt;/strong&gt; No other supply chain tool does this. With &lt;a href="https://glasp.co/articles/mcp-security-tool-poisoning-supply-chain" rel="noopener noreferrer"&gt;MCP tool poisoning&lt;/a&gt; becoming a real attack vector in 2026, a tool that catalogs which MCP servers are configured on a machine is ahead of the curve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NDJSON output.&lt;/strong&gt; Every record is a self-contained JSON line. Pipe it through &lt;code&gt;jq&lt;/code&gt;, feed it to your SIEM, aggregate it with &lt;code&gt;xargs&lt;/code&gt;. No proprietary format, no dashboard required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only guarantee.&lt;/strong&gt; I checked the source. There are no &lt;code&gt;os.Create&lt;/code&gt;, no &lt;code&gt;os.WriteFile&lt;/code&gt;, no &lt;code&gt;exec.Command&lt;/code&gt; calls outside the self-test fixtures. The binary genuinely can't modify your system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Doesn't Do
&lt;/h2&gt;

&lt;p&gt;The biggest practical limitation is version range matching. Bumblebee v0.1 only matches exact name + version pairs. If an advisory says "all versions below 2.3.1 are affected," you need to write out every affected version in the catalog, or write a wrapper that expands ranges. Most advisories use ranges, so this will bite you early.&lt;/p&gt;

&lt;p&gt;MCP config parsing is JSON-only. Continue uses YAML, Codex uses TOML, and both are skipped silently. Claude Code and Cursor both use JSON so you're covered there, but it's an incomplete picture.&lt;/p&gt;

&lt;p&gt;There's no remediation. Bumblebee tells you what's there. It won't remove it, update it, or file a PR. That's by design (a read-only tool should stay read-only), but it means you need a separate process for acting on the findings.&lt;/p&gt;

&lt;p&gt;There's also no continuous monitoring. It's a one-shot execution model. You run it, get results, done. If you want recurring scans, wrap it in a cron job or MDM policy. No agent, no daemon, no phone-home.&lt;/p&gt;

&lt;p&gt;Finally, Linux and macOS only. No Windows support in v0.1.1. The scan profiles are built around Unix filesystem conventions. If your team has Windows developers, Bumblebee misses them entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP Angle
&lt;/h2&gt;

&lt;p&gt;I want to call out the MCP scanning specifically because it's the feature that separates Bumblebee from every other supply chain tool I've used.&lt;/p&gt;

&lt;p&gt;Your &lt;code&gt;claude_desktop_config.json&lt;/code&gt; or project-level &lt;code&gt;mcp.json&lt;/code&gt; defines which tools your AI coding agent can call. A &lt;a href="https://www.danilchenko.dev/posts/litellm-vulnerability/" rel="noopener noreferrer"&gt;compromised MCP server&lt;/a&gt; gets tool-level access inside your development session. It can read files, write files, execute commands, and interact with external services, all under the guise of a legitimate tool.&lt;/p&gt;

&lt;p&gt;The Miasma worm that hit in June 2026 specifically targeted these config files, injecting itself into SessionStart hooks across 13 AI coding tools. Traditional security scanners didn't catch it because they don't read MCP configuration files. Bumblebee does.&lt;/p&gt;

&lt;p&gt;When I ran the baseline scan, it found three MCP servers in my Claude Code config: &lt;code&gt;filesystem-mcp-server&lt;/code&gt;, &lt;code&gt;brave-search&lt;/code&gt;, and a custom internal tool. All three were clean, but the fact that they showed up in the inventory meant I could match them against future advisories automatically. That's the workflow: scan once, match many times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Bumblebee in a Team
&lt;/h2&gt;

&lt;p&gt;For individual developers, the one-shot model works fine. For a team, you need a bit more structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bumblebee scan &lt;span class="nt"&gt;--profile&lt;/span&gt; baseline &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exposure-catalog&lt;/span&gt; /shared/catalogs/latest.json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select(.record_type == "finding")'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-d&lt;/span&gt; @- https://internal-siem.company.com/ingest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern is: scan locally, filter for findings, push to a central collector. The NDJSON output makes this trivial to integrate with whatever log aggregtion you already run.&lt;/p&gt;

&lt;p&gt;For MDM-managed fleets, distribute the binary via your existing tooling (it's a single static file, no installer needed), set up a scheduled task, and point &lt;code&gt;--exposure-catalog&lt;/code&gt; at a shared network path that your security team updates when advisories drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Bumblebee replace Snyk or Socket?
&lt;/h3&gt;

&lt;p&gt;No. Bumblebee scans what's already on your machine. Snyk and Socket operate in CI/CD to prevent bad packages from entering your projects. Use all three. They cover different phases of the supply chain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Bumblebee trigger malicious code during a scan?
&lt;/h3&gt;

&lt;p&gt;No. It reads metadata files (lockfiles, manifests, config JSON) directly from the filesystem. It never executes &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;pip install&lt;/code&gt;, or any lifecycle hooks. The scan can't trigger a compromised package's install script.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it work with private registries?
&lt;/h3&gt;

&lt;p&gt;Yes, for inventory purposes. Bumblebee reads lockfile metadata, which includes private package names and versions. It doesn't authenticate against registries. It reads from local disk, so there's nothing to authenticate against.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I get exposure catalogs?
&lt;/h3&gt;

&lt;p&gt;You build them. Bumblebee doesn't ship with a built-in advisory database. When an advisory drops (from GitHub Security Advisories, Snyk's database, or your own threat intel), you convert it into the catalog JSON format and pass it to &lt;code&gt;--exposure-catalog&lt;/code&gt;. Multiple catalogs merge when you point at a directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a Windows version?
&lt;/h3&gt;

&lt;p&gt;Not yet. Bumblebee v0.1.1 supports macOS and Linux only. Windows support would require rewriting the scan profile filesystem paths and adding support for Windows-specific package manager locations (AppData, ProgramFiles).&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/perplexityai/bumblebee" rel="noopener noreferrer"&gt;Perplexity Bumblebee GitHub repository&lt;/a&gt;: source code, documentation, and release notes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.marktechpost.com/2026/05/23/perplexity-open-sources-bumblebee-a-read-only-supply-chain-scanner-for-developer-endpoints/" rel="noopener noreferrer"&gt;MarkTechPost coverage of Bumblebee release&lt;/a&gt;: independent analysis of the tool's architecture and purpose&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.sonatype.com/state-of-the-software-supply-chain/2026" rel="noopener noreferrer"&gt;Sonatype 2026 State of the Software Supply Chain&lt;/a&gt;: 454,600 malicious packages in 2025, 1.233M cumulative&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://glasp.co/articles/mcp-security-tool-poisoning-supply-chain" rel="noopener noreferrer"&gt;MCP Security in 2026: Tool Poisoning and Supply Chain Risks&lt;/a&gt;: analysis of MCP-specific attack vectors&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://phoenix.security/accelerating-supply-chain-attacks-npm-pypi-vsx-ai-enabled-2026/" rel="noopener noreferrer"&gt;Supply Chain Attacks 2026: npm, PyPI, VS Code, AI Agents&lt;/a&gt;: attack campaign tracking and 2.3x AI-agent skill risk data&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/alanwest/bumblebee-vs-osv-scanner-two-takes-on-supply-chain-scanning-j29"&gt;Bumblebee vs OSV-Scanner comparison on Dev.to&lt;/a&gt;: positioning against other scanning tools&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;Bumblebee does one thing and does it well: it inventories every package, extension, and MCP config on a developer machine without executing anything. That narrow scope is a feature. 454,600 malicious packages shipped in a single year. AI coding tools introduced a new attack surface nobody was scanning. A sub-4-second read-only answer to "are we exposed?" is worth adding to your toolkit.&lt;/p&gt;

&lt;p&gt;The version range limitation is real and will bite you on the first advisory that specifies a range instead of exact versions. The missing Windows support matters if you have a mixed fleet. But for Unix-based development teams using Claude Code, Cursor, or VS Code, Bumblebee fills a gap that Snyk, Socket, and npm audit don't touch: what's actually sitting on the developer's disk right now, including the MCP configs that no other tool bothers to read.&lt;/p&gt;

&lt;p&gt;Install it. Run the self-test. Point it at your home directory. The scan takes less time than reading this sentence, and you might not like what it finds.&lt;/p&gt;

</description>
      <category>bumblebee</category>
      <category>perplexity</category>
      <category>supplychainsecurity</category>
      <category>go</category>
    </item>
    <item>
      <title>Is There a Backend-as-a-Service for AI Coding Agents?</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Fri, 26 Jun 2026 14:15:13 +0000</pubDate>
      <link>https://dev.to/dmaxdev/is-there-a-backend-as-a-service-for-ai-coding-agents-3jh7</link>
      <guid>https://dev.to/dmaxdev/is-there-a-backend-as-a-service-for-ai-coding-agents-3jh7</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Yes. A handful of backends now target AI coding agents directly. InsForge, Base44, Butterbase, and MoonDB are among them. What sets them apart from Supabase or Firebase is that an agent can provision the whole thing from code: a JSON schema in, a hosted database with auth and a REST API out, no dashboard click-through. With MoonDB that's three calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "designed for AI coding agents" actually means
&lt;/h2&gt;

&lt;p&gt;Most backends were built for a person. You sign in, click to create tables, wire up auth in a settings panel, copy keys out of a dashboard, and paste them into your app. An agent like &lt;a href="https://moondb.ai/for/cursor" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt; or &lt;a href="https://moondb.ai/for/claude" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; can't click. Drop one into that flow and it stalls at the first screen that needs a human.&lt;/p&gt;

&lt;p&gt;An agent-native backend removes the human step. The whole setup runs over HTTP or through an &lt;a href="https://moondb.ai/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;, so the agent does the work end to end. Three things make that possible, and they're the test for whether a backend really fits an agent:&lt;/p&gt;

&lt;p&gt;It provisions from code. Creating a project, applying a schema, and reading the resulting keys all happen through API calls that return JSON. Nothing waits on a console.&lt;/p&gt;

&lt;p&gt;It's declarative. The agent writes &lt;em&gt;what&lt;/em&gt; the data looks like: tables, fields, relationships, and who can read and write each one. The service figures out the SQL and runs the migration. No hand-written DDL, no guessing at migration order.&lt;/p&gt;

&lt;p&gt;Its errors are instructions. When a schema is wrong, the response says what to fix, not just that something broke. An agent reads that and corrects itself on the next call instead of looping.&lt;/p&gt;

&lt;p&gt;Supabase and Firebase can be scripted, and people do script them. But the happy path still runs through a dashboard, the client libraries assume a build step, and the error messages are written for a developer reading a stack trace. In the newer tools, the agent is the primary user and the human reviews the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backends built for this, as of 2026
&lt;/h2&gt;

&lt;p&gt;The category is small, and it already has a name: "agent-native" backends. A few worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://insforge.dev" rel="noopener noreferrer"&gt;InsForge&lt;/a&gt; is an open-source backend that bundles a stack of services behind a semantic layer an agent can read and provision: Postgres with auto-generated APIs, auth, S3-compatible storage, edge functions, compute and hosting, and an AI model gateway. The &lt;a href="https://github.com/InsForge/insforge" rel="noopener noreferrer"&gt;source is on GitHub&lt;/a&gt; under Apache-2.0.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://base44.com/backend" rel="noopener noreferrer"&gt;Base44&lt;/a&gt; leans on its CLI, which it tunes for Claude Code and Cursor so an agent can scaffold backend logic, data models, and APIs from a prompt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://butterbase.ai" rel="noopener noreferrer"&gt;Butterbase&lt;/a&gt; is another open-source option: Postgres, auth, storage, functions, an AI gateway, and an MCP server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.xano.com" rel="noopener noreferrer"&gt;Xano&lt;/a&gt; comes at it from the no-code side. It's a visual backend builder that's added AI features for generating APIs and logic.&lt;/p&gt;

&lt;p&gt;MoonDB sits at the minimal end of that list, and on purpose. It does one thing: turn a JSON schema into a hosted database with a REST API, auth, and file storage, all reachable over plain HTTP or MCP. There's no vector store, no edge-function runtime, no AI gateway. If your generated app needs RAG or a serverless compute layer, InsForge or Butterbase carry more out of the box. If it needs a database with auth and a clean API that an agent can stand up in seconds and a person can actually understand, the smaller surface is the point.&lt;/p&gt;

&lt;p&gt;The honest framing: on a feature checklist, Supabase wins. The question that sorts this category is which backend an agent can drive to "done" without a human unblocking it, and how little is left to misunderstand once it's running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provision a MoonDB backend the way an agent would
&lt;/h2&gt;

&lt;p&gt;Here's the full path an agent follows to back a generated app, with a to-do app as the canonical first build. Three calls, no dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: create a project
&lt;/h3&gt;

&lt;p&gt;One call returns the keys the rest of the flow needs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://moondb.ai/v1/projects &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: mk_your_account_key"&lt;/span&gt; &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;'{ "name": "todo-app" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"p_7k2d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"admin_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"public_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pk_..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &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;&lt;em&gt;The agent reads three keys from the JSON: an account key that manages projects, an admin key that changes the schema, and a public key that's safe to ship in client code.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The agent keeps the &lt;code&gt;admin_key&lt;/code&gt; server-side and uses the &lt;code&gt;public_key&lt;/code&gt; for anything that runs in a browser. No human copies anything out of a console.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: send the schema
&lt;/h3&gt;

&lt;p&gt;The agent describes the data as one JSON document and applies it with a single PUT. A to-do app needs users and per-user todos, where each person only sees their own list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT https://moondb.ai/p/p_7k2d/v1/schema &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Admin-Key: sk_..."&lt;/span&gt; &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; @schema.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tables"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"auth_table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"columns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"display_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"todos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"columns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string required max_length 200"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bool default false"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ref users required"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"owner_field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"read"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"create"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"authenticated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"update"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"delete"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"owner"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;That document carries the whole security model. &lt;code&gt;"auth_table": true&lt;/code&gt; makes &lt;code&gt;users&lt;/code&gt; the login table, so MoonDB adds a hidden, required &lt;code&gt;password_hash&lt;/code&gt; column and serves signup and login endpoints. The agent never declares the password field. &lt;code&gt;"ref users required"&lt;/code&gt; ties each todo to a user. The &lt;code&gt;owner_field&lt;/code&gt; with &lt;code&gt;"read": "owner"&lt;/code&gt; scopes every read to the logged-in user, so one person's todos never leak into another's response, and the agent didn't write a line of authorization code to get that. &lt;code&gt;create: authenticated&lt;/code&gt; lets any signed-in user add a todo. MoonDB also adds &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, and &lt;code&gt;updated_at&lt;/code&gt; to every table on its own.&lt;/p&gt;

&lt;p&gt;MoonDB reads that, creates the tables, and serves the API immediately. Change the schema later and the agent sends new JSON; MoonDB diffs the two versions and migrates, and it refuses to run a destructive change unless the request opts in. That guardrail keeps an over-eager agent from dropping a column full of data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flx7t172vpukavg7y9jeh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flx7t172vpukavg7y9jeh.png" alt="The MoonDB dashboard schema editor showing the todos and users tables generated from the JSON schema" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The dashboard renders what the schema produced. An agent never opens this. It's here for the human to check the work.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: use the generated API
&lt;/h3&gt;

&lt;p&gt;The endpoints exist the moment the schema applies. The agent signs up an end user, gets a token back, and writes a todo as that user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create an end user&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://moondb.ai/p/p_7k2d/auth/signup &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;'{ "email": "sam@example.com", "password": "s3cret-pass", "display_name": "Sam" }'&lt;/span&gt;
&lt;span class="c"&gt;# → { "data": { "user": {...}, "token": "&amp;lt;jwt&amp;gt;", "refresh_token": "...", "expires_in": 3600 } }&lt;/span&gt;

&lt;span class="c"&gt;# Add a todo as that user&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://moondb.ai/p/p_7k2d/api/todos &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;jwt&amp;gt;"&lt;/span&gt; &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;'{ "title": "Ship the MVP", "priority": "high" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The todo lands with &lt;code&gt;user_id&lt;/code&gt; set to Sam from the token, because the &lt;code&gt;owner_field&lt;/code&gt; rule stamps ownership at write time. When Sam reads &lt;code&gt;GET /p/p_7k2d/api/todos&lt;/code&gt;, the &lt;code&gt;read: owner&lt;/code&gt; rule scopes the result to Sam's rows. The agent gets working auth and per-user data isolation out of the schema, not out of a pile of middleware it had to write and test.&lt;/p&gt;

&lt;p&gt;That's the loop a coding agent can run unattended: create, describe, write. For an agent that speaks &lt;a href="https://moondb.ai/mcp" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;, MoonDB exposes the same provisioning as MCP tools, so the steps above become tool calls inside the agent's session instead of shell commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the agent-native backends compare
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Supabase / Firebase&lt;/th&gt;
&lt;th&gt;InsForge / Butterbase&lt;/th&gt;
&lt;th&gt;MoonDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Built for&lt;/td&gt;
&lt;td&gt;A developer at a dashboard&lt;/td&gt;
&lt;td&gt;AI coding agents&lt;/td&gt;
&lt;td&gt;AI coding agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How an agent provisions it&lt;/td&gt;
&lt;td&gt;Scripted around a dashboard flow&lt;/td&gt;
&lt;td&gt;CLI + MCP&lt;/td&gt;
&lt;td&gt;Three HTTP calls or MCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema source&lt;/td&gt;
&lt;td&gt;SQL, dashboard, or migrations&lt;/td&gt;
&lt;td&gt;Declarative + AI generation&lt;/td&gt;
&lt;td&gt;One JSON document&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;SQLite on Cloudflare D1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra services&lt;/td&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;td&gt;Vector DB, edge functions, AI gateway&lt;/td&gt;
&lt;td&gt;Auth and file storage only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access rules&lt;/td&gt;
&lt;td&gt;Row-level security policies&lt;/td&gt;
&lt;td&gt;Per-service config&lt;/td&gt;
&lt;td&gt;In the schema (&lt;code&gt;owner&lt;/code&gt; / &lt;code&gt;authenticated&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Firebase and Supabase are the right call when you want their depth and don't mind that an agent needs hand-holding to drive them. InsForge and Butterbase fit when a generated app needs vector search or serverless functions alongside the database. MoonDB fits when the job is a database with auth and a REST API, and the thing you care about is that an agent can stand it up in one pass and a person can read the whole schema in one screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where MoonDB stops
&lt;/h2&gt;

&lt;p&gt;Be clear-eyed about the trade. MoonDB runs on SQLite through Cloudflare D1, which buys fast reads at the edge and a model with almost nothing to configure. It also sets the ceiling: one writer per database and a 10 GB cap. That holds a to-do app, a CRM for a small team, or a content site with room to spare, but a write-heavy system with thousands of concurrent writers wants a dedicated Postgres. If you're weighing the two, &lt;a href="https://moondb.ai/vs/supabase" rel="noopener noreferrer"&gt;here's how MoonDB compares to Supabase&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It's also not a vector database. If your app is doing retrieval-augmented generation and needs embeddings stored next to your data, that's a real reason to look at InsForge's vector support or a dedicated vector DB instead. MoonDB stores structured application data, the rows your generated app reads and writes, and leaves RAG to tools built for it.&lt;/p&gt;

&lt;p&gt;For the range most agent-built apps start in, a schema an agent can write and a REST API it can call beats a feature list it can't reach. The &lt;a href="https://moondb.ai/docs" rel="noopener noreferrer"&gt;schema and API reference&lt;/a&gt; covers every field type, and the walkthrough on &lt;a href="https://moondb.ai/blog/generate-rest-api-from-json-schema" rel="noopener noreferrer"&gt;generating a REST API from a JSON schema&lt;/a&gt; goes deeper on the format. If you're comparing agent-facing database tools specifically, the rundown of &lt;a href="https://moondb.ai/blog/database-mcp-server" rel="noopener noreferrer"&gt;database MCP servers&lt;/a&gt; covers that angle.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is there a backend-as-a-service designed for AI coding agents?
&lt;/h3&gt;

&lt;p&gt;Yes. Several launched recently: InsForge, Base44, Butterbase, and MoonDB among them. They differ from Supabase or Firebase in one concrete way: an agent can provision the whole backend from code, with no dashboard step. You hand over a schema and get a live database, auth, and a REST API back.&lt;/p&gt;

&lt;h3&gt;
  
  
  What makes a backend agent-native instead of a normal BaaS?
&lt;/h3&gt;

&lt;p&gt;A normal BaaS assumes a person clicking through a dashboard to make tables and copy keys. An agent-native one is driven entirely by API: the agent sends a declarative schema, reads keys from the JSON response, and gets errors written as instructions it can act on. No human step sits in the middle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a coding agent like Cursor or Claude Code set up a backend by itself?
&lt;/h3&gt;

&lt;p&gt;Yes. If the backend exposes provisioning over HTTP or an MCP server, the agent can create a project, apply a schema, and start writing data without you touching a console. MoonDB does this in three calls; tools like Base44 and InsForge expose a CLI or MCP server aimed at Claude Code and Cursor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need to write SQL or use a dashboard?
&lt;/h3&gt;

&lt;p&gt;No. With a declarative backend you describe tables, fields, and access rules as JSON, and the service compiles that to SQL and runs the migration. The dashboard becomes optional. It's useful for watching what the agent built, but you don't need it to build anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://insforge.dev" rel="noopener noreferrer"&gt;InsForge&lt;/a&gt; and its &lt;a href="https://github.com/InsForge/insforge" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;: open-source agent-native backend&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://base44.com/backend" rel="noopener noreferrer"&gt;Base44 backend&lt;/a&gt;: backend with a CLI tuned for Claude Code and Cursor&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://butterbase.ai" rel="noopener noreferrer"&gt;Butterbase&lt;/a&gt;: open-source BaaS with MCP&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.xano.com/blog/best-backends-for-ai-generated-apps/" rel="noopener noreferrer"&gt;Xano: best backends for AI-generated apps&lt;/a&gt;: the no-code vendor's take on the category&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://moondb.ai/docs" rel="noopener noreferrer"&gt;MoonDB documentation&lt;/a&gt;: the schema format and full API reference&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>GLM-5.2 Review: 753B Open-Weight Model That Undercuts GPT-5.5</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Thu, 18 Jun 2026 08:42:42 +0000</pubDate>
      <link>https://dev.to/dmaxdev/glm-52-review-753b-open-weight-model-that-undercuts-gpt-55-37ic</link>
      <guid>https://dev.to/dmaxdev/glm-52-review-753b-open-weight-model-that-undercuts-gpt-55-37ic</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Z.ai's GLM-5.2 is a 753-billion-parameter mixture-of-experts model released under an MIT license on June 16, 2026. It tops GPT-5.5 on SWE-bench Pro (62.1 vs 58.6) and trails Claude Opus 4.8 by about 0.7 points on FrontierSWE (74.4% vs 75.1%). Through OpenRouter, it runs at $1.40 per million input tokens, about 72% cheaper than Claude and GPT. The catch: if you use Z.ai's hosted API instead of self-hosting the weights, your prompts route through servers governed by China's National Intelligence Law.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GLM-5.2 Actually Is
&lt;/h2&gt;

&lt;p&gt;GLM-5.2 comes from Z.ai (formerly Zhipu AI), one of China's largest AI labs and one of the few on the US Bureau of Industry and Security's Entity List. The model launched on June 13 for paying subscribers, then dropped its full open weights on Hugging Face three days later at &lt;a href="https://huggingface.co/zai-org/GLM-5.2" rel="noopener noreferrer"&gt;zai-org/GLM-5.2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The architecture is a mixture-of-experts (MoE) transformer: 753 billion total parameters, but only about 40 billion active per forward pass. That MoE structure is why it can match or beat dense models with far fewer FLOPs per token. It supports a 1-million-token context window (a 5x jump from GLM-5.1's 200K) and can produce up to 128K tokens in a single response.&lt;/p&gt;

&lt;p&gt;I spent the last day running it through OpenRouter on three personal projects: a Flask API refactor, a Go CLI tool with nested subcommands, and a long context window test stuffing 400K tokens of a monorepo into the prompt. Not cherry-picked benchmarks, just the kind of work I do daily with Claude Code and occasionally Codex.&lt;/p&gt;

&lt;p&gt;The first thing I noticed: GLM-5.2 is verbose. It used roughly 43K output tokens per task in Artificial Analysis's benchmark suite, compared to 26K for GLM-5.1. That verbosity inflates your bill if you're paying per token, and it slows down iteration when you're waiting for a response. On OpenRouter, a typical coding task took around 75 seconds, which felt slow compared to Opus 4.8's sub-30-second responses for similar complexity.&lt;/p&gt;

&lt;p&gt;But the code it wrote was clean. A Python web scraper function came back production-ready on the first try: proper error handling, retries with exponential backoff, typed return values. The Go CLI output was similarly solid: correct cobra subcommand nesting, help text, and flag parsing without the usual LLM quirk of inventing nonexistent stdlib packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark Breakdown
&lt;/h2&gt;

&lt;p&gt;The full picture, pulling from &lt;a href="https://artificialanalysis.ai/articles/glm-5-2-is-the-new-leading-open-weights-model-on-the-artificial-analysis-intelligence-index" rel="noopener noreferrer"&gt;Artificial Analysis&lt;/a&gt;, BenchLM, and Z.ai's published results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;GLM-5.2&lt;/th&gt;
&lt;th&gt;Claude Opus 4.8&lt;/th&gt;
&lt;th&gt;GPT-5.5&lt;/th&gt;
&lt;th&gt;DeepSeek V4 Pro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SWE-bench Pro&lt;/td&gt;
&lt;td&gt;62.1&lt;/td&gt;
&lt;td&gt;69.2&lt;/td&gt;
&lt;td&gt;58.6&lt;/td&gt;
&lt;td&gt;55.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminal-Bench 2.1&lt;/td&gt;
&lt;td&gt;81.0&lt;/td&gt;
&lt;td&gt;85.0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FrontierSWE&lt;/td&gt;
&lt;td&gt;74.4%&lt;/td&gt;
&lt;td&gt;75.1%&lt;/td&gt;
&lt;td&gt;72.6%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AA Intelligence Index&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GDPval-AA v2 (Agentic)&lt;/td&gt;
&lt;td&gt;1524&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1514&lt;/td&gt;
&lt;td&gt;1328&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPQA Diamond&lt;/td&gt;
&lt;td&gt;89%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HLE&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things jump out from this table.&lt;/p&gt;

&lt;p&gt;GLM-5.2 beats GPT-5.5 on every coding benchmark where both have scores (see our &lt;a href="https://www.danilchenko.dev/posts/gpt-5-5-review/" rel="noopener noreferrer"&gt;GPT-5.5 review&lt;/a&gt; for the full breakdown on that model). The SWE-bench Pro gap (62.1 vs 58.6) is real. That's 3.5 points on a benchmark where single-digit gaps separate model generations. On FrontierSWE, GLM-5.2 lands at 74.4%, within 0.7% of Opus 4.8's 75.1% (we covered &lt;a href="https://www.danilchenko.dev/posts/claude-fable-5-review/" rel="noopener noreferrer"&gt;Fable 5's benchmark claims&lt;/a&gt; recently). A 0.7% gap for a model that costs 80% less.&lt;/p&gt;

&lt;p&gt;The agentic score (GDPval-AA v2) stands out: 1524 vs GPT-5.5's 1514. GLM-5.2 is built for long-horizon agent workflows where the model needs to plan across files, run commands, and iterate. Z.ai specifically pitched it as a coding-agent model, and the benchmarks back that framing.&lt;/p&gt;

&lt;p&gt;Where it falls short: hallucination rate sits at 28.1% on the AA-Omniscience Index. That's an improvement over GLM-5.1's 29.4%, but it means roughly one in four factual claims from the model is wrong. For coding tasks this is less of a concern (the compiler catches lies), but for anything research-heavy or fact-dependent, you'll want external verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing: The Real Reason to Pay Attention
&lt;/h2&gt;

&lt;p&gt;Pricing changes how you actually use a model more than benchmarks do.&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;Input (per 1M)&lt;/th&gt;
&lt;th&gt;Output (per 1M)&lt;/th&gt;
&lt;th&gt;Approx. cost/task&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5.2 (OpenRouter)&lt;/td&gt;
&lt;td&gt;$1.40&lt;/td&gt;
&lt;td&gt;$4.40&lt;/td&gt;
&lt;td&gt;~$0.46&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.8&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;td&gt;$25.00&lt;/td&gt;
&lt;td&gt;~$2.50&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;td&gt;$30.00&lt;/td&gt;
&lt;td&gt;~$3.00&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek V4 Pro&lt;/td&gt;
&lt;td&gt;$0.44&lt;/td&gt;
&lt;td&gt;$0.87&lt;/td&gt;
&lt;td&gt;~$0.05&lt;/td&gt;
&lt;td&gt;DeepSeek&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5.1&lt;/td&gt;
&lt;td&gt;~$1.00&lt;/td&gt;
&lt;td&gt;~$3.00&lt;/td&gt;
&lt;td&gt;~$0.25&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GLM-5.2 isn't the cheapest model in this table. DeepSeek V4 Pro undercuts it by 10x. But DeepSeek V4 Pro also scores significantly lower on every coding benchmark. The interesting position is GLM-5.2 vs the proprietary frontier: you get 95-100% of Claude Opus 4.8's coding performance for roughly 18% of the price.&lt;/p&gt;

&lt;p&gt;The caveat is token consumption. GLM-5.2 burns through 43K output tokens per benchmark task, with 37K of those being internal reasoning tokens. You're billed for the reasoning tokens even though they don't appear in the visible output. That inflates the per-task cost from what the raw price-per-million suggests. At ~$0.46 per coding task, it's still cheap against proprietary options, but it's almost double GLM-5.1's ~$0.25.&lt;/p&gt;

&lt;p&gt;For personal projects and prototyping, the math is obvious: GLM-5.2 through OpenRouter gives you frontier-adjacent coding quality at indie-developer prices (compare this with the &lt;a href="https://www.danilchenko.dev/posts/claude-code-max-vs-pro/" rel="noopener noreferrer"&gt;Claude Code Pro vs Max pricing breakdown&lt;/a&gt;). For production agent pipelines processing thousands of tasks, the token verbosity starts to add up and DeepSeek V4 Pro's cost advantage gets harder to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1-Million-Token Context Window
&lt;/h2&gt;

&lt;p&gt;GLM-5.1 topped out at 200K tokens. GLM-5.2 jumps to 1 million, matching Claude Opus 4.8 and GPT-5.5's API context window.&lt;/p&gt;

&lt;p&gt;I tested this by feeding it roughly 400K tokens of a monorepo: the entire &lt;code&gt;src/&lt;/code&gt; directory of a mid-size Flask application with about 180 files. I asked it to trace a specific request flow through three microservices, identify where a race condition could happen, and propose a fix.&lt;/p&gt;

&lt;p&gt;It handled the context without obvious degradation. The trace was accurate, it identified the correct database transaction that lacked proper isolation, and the fix was structurally sound. Whether it would hold up at 800K+ tokens I can't say. I didn't have a codebase that large on hand to test with.&lt;/p&gt;

&lt;p&gt;Z.ai specifically designed GLM-5.2's IndexShare sparse-attention mechanism for long agent trajectories. The idea is that coding agents accumulate hundreds of thousands of tokens over a multi-step session: file reads, command outputs, error traces, iterative fixes. A model that degrades at 200K would force the agent to constanly prune context or restart. At 1M, the agent can carry the full project state through a long session without losing earlier context.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Use GLM-5.2
&lt;/h2&gt;

&lt;p&gt;Three paths, each with different trade-offs.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenRouter (quickest start)
&lt;/h3&gt;

&lt;p&gt;The fastest way to try GLM-5.2. Nine providers offer it on OpenRouter, all at roughly $1.40/$4.40 per million tokens. The API is OpenAI-compatible:&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;https://openrouter.ai/api/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;your-openrouter-key&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;response&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;z-ai/glm-5.2&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="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;Refactor this Flask route to use async SQLAlchemy 2.0 sessions&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;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4096&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;response&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;h3&gt;
  
  
  Z.ai's API (official, but read the data section below)
&lt;/h3&gt;

&lt;p&gt;Z.ai's own API supports OpenAI SDK compatibility and adds a Coding Plan tier that's compatible with Claude Code, Cline, and Cursor. Point your tools at the coding endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://api.z.ai/api/coding/paas/v4"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_DEFAULT_SONNET_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"glm-5.2[1m]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you GLM-5.2 as a drop-in replacement in Claude Code sessions. I tried this with a small Go project and it worked. Claude Code's agent loop ran normally, though response times were noticeably slower than Anthropic's own endpoints.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-Hosting (full control, big hardware)
&lt;/h3&gt;

&lt;p&gt;The weights are on Hugging Face under MIT. You can serve them with vLLM or SGLang on your own GPUs. The hardware requirement is steep: at FP8 quantization, you need about 753 GB of VRAM. That's ten H100 80GB GPUs (800 GB total) or a smaller cluster of H200s. At BF16, the requirement doubles to ~1,500 GB.&lt;/p&gt;

&lt;p&gt;Self-hosting makes sense for two audiences: enterprises that can't send code to external APIs for compliance reasons, and AI labs that want to fine-tune the weights for specialized domains. For everyone else, OpenRouter is simpler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Example vLLM serving command (8x H100)&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; vllm.entrypoints.openai.api_server &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--model&lt;/span&gt; zai-org/GLM-5.2 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 8 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--dtype&lt;/span&gt; float8 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 1048576
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The China Data Question
&lt;/h2&gt;

&lt;p&gt;Z.ai is headquartered in Beijing. The US Bureau of Industry and Security &lt;a href="https://www.federalregister.gov/documents/2025/01/16/2025-00704/" rel="noopener noreferrer"&gt;added Zhipu AI&lt;/a&gt; (Z.ai's former name) to its Entity List in January 2025, citing the company's role in advancing military AI modernization. In late April 2026, US House lawmakers &lt;a href="https://homeland.house.gov/" rel="noopener noreferrer"&gt;opened a formal inquiry&lt;/a&gt; into cybersecurity risks posed by Chinese AI models in critical infrastructure, naming Z.ai alongside DeepSeek, MiniMax, ByteDance, and several others.&lt;/p&gt;

&lt;p&gt;The risk depends entirely on the serving path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you use Z.ai's hosted API&lt;/strong&gt;, your prompts and code route through servers subject to China's National Intelligence Law, which requires Chinese companies to cooperate with government data requests. For regulated industries (healthcare, finance, defense, government contracting), this is a non-starter. For any codebase containing proprietary algorithms, API keys, customer data, or anything you wouldn't post publicly, routing through Z.ai's API is a risk most security teams won't approve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you self-host or use a Western OpenRouter provider&lt;/strong&gt;, the data never touches Z.ai's servers. The MIT license has no phone-home requirements, no telemetry obligations, no usage restrictions. You download the weights, serve them on your own infrastructure, and Z.ai has no visibility into what you're doing. This is the scenario where the open-weights value proposition fully pays off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you use OpenRouter&lt;/strong&gt;, check which provider is serving your request. Most OpenRouter providers for GLM-5.2 route through non-Chinese infrastructure, but the routing can vary. Verify with your specific provider if this matters for your compliance requirements.&lt;/p&gt;

&lt;p&gt;The practical upshot: GLM-5.2's China connection is a non-issue if you self-host, a manageable concern on most OpenRouter providers, and a hard blocker if you'd be sending sensitive code directly to Z.ai. The MIT license exists precisely to decouple the model's capabilities from the company's jurisdiction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Use GLM-5.2
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Good fit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Independent developers and small teams who want near-frontier coding quality at $1.40/M input instead of $5.00/M. The cost difference compounds fast when you're running agent loops that process dozens of files.&lt;/li&gt;
&lt;li&gt;Teams already invested in open-weight infrastructure (vLLM clusters, self-hosted inference). GLM-5.2 slots into that stack with no vendor lock-in and no API dependency.&lt;/li&gt;
&lt;li&gt;Long-context use cases where the 1M window matters: codebase-wide refactors, multi-file agents, repository Q&amp;amp;A.&lt;/li&gt;
&lt;li&gt;Anyone who needs to fine-tune a frontier-class coding model for a specific domain. MIT license means no restrictions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Bad fit:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enterprise teams in regulated industries that can't risk any data routing through Chinese infrastructure, unless they have the GPU budget to self-host.&lt;/li&gt;
&lt;li&gt;Latency-sensitive production pipelines. At ~75 seconds per coding task through OpenRouter, GLM-5.2 is sluggish compared to Opus 4.8 or GPT-5.5's APIs.&lt;/li&gt;
&lt;li&gt;Tasks requiring low hallucination rates. The 28.1% factual error rate is acceptable for code (where tests catch mistakes) but rough for content generation, research synthesis, or customer-facing text.&lt;/li&gt;
&lt;li&gt;Teams that need multimodal capabilities. GLM-5.2 is text-only. Z.ai's vision model (GLM-5V-Turbo) exists separately and isn't open-weights.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is GLM-5.2 better than Claude Opus 4.8 for coding?
&lt;/h3&gt;

&lt;p&gt;Not quite. On SWE-bench Pro, Opus 4.8 scores 69.2 vs GLM-5.2's 62.1, a 7-point gap. On FrontierSWE, it narrows to 75.1% vs 74.4%. Opus 4.8 is faster, less verbose, and has lower hallucination rates. But it costs 3-5x more per token. Whether "better" means "higher score" or "better value" depends on your budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run GLM-5.2 locally?
&lt;/h3&gt;

&lt;p&gt;Technically yes, if you have ~753 GB of VRAM (ten H100 80GB GPUs at FP8). For most developers, local running isn't practical. Cloud hosting through a managed provider or OpenRouter is the realistic path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is GLM-5.2 safe to use with proprietary code?
&lt;/h3&gt;

&lt;p&gt;It depends on the serving path. Self-hosted or through a trusted Western provider on OpenRouter — yes, your data doesn't leave your infrastructure. Through Z.ai's own API — your prompts traverse Chinese servers governed by the National Intelligence Law. Most enterprise security teams will block the latter.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does GLM-5.2 compare to DeepSeek V4 Pro?
&lt;/h3&gt;

&lt;p&gt;DeepSeek V4 Pro is ~10x cheaper ($0.44/$0.87 per million tokens) as we detailed in our &lt;a href="https://www.danilchenko.dev/posts/deepseek-v4-pro-review/" rel="noopener noreferrer"&gt;DeepSeek V4 Pro review&lt;/a&gt;, but scores lower on coding benchmarks — 55.4 on SWE-bench Pro vs GLM-5.2's 62.1. DeepSeek wins on cost, GLM-5.2 wins on capability. Both carry similar China-data concerns if used through their respective hosted APIs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is GLM-5.2 so verbose?
&lt;/h3&gt;

&lt;p&gt;The model produces about 43K output tokens per coding task, with 37K being internal reasoning tokens that get billed but don't appear in the response. Deeper reasoning chains improve accuracy on complex tasks, but they inflate costs and latency. Z.ai hasn't offered a "low verbosity" mode that trades some accuracy for speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.z.ai/guides/llm/glm-5.2" rel="noopener noreferrer"&gt;Z.ai GLM-5.2 documentation&lt;/a&gt; — official specs, API reference, and capability overview&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/2026/Jun/17/glm-52/" rel="noopener noreferrer"&gt;Simon Willison's analysis of GLM-5.2&lt;/a&gt; — independent benchmarks and observations from the day of release&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://artificialanalysis.ai/articles/glm-5-2-is-the-new-leading-open-weights-model-on-the-artificial-analysis-intelligence-index" rel="noopener noreferrer"&gt;Artificial Analysis: GLM-5.2 is the new leading open-weights model&lt;/a&gt; — Intelligence Index v4.1 scores, GDPval-AA agentic benchmarks, and pricing data&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://venturebeat.com/technology/z-ais-open-weights-glm-5-2-beats-gpt-5-5-on-multiple-long-horizon-coding-benchmarks-for-1-6th-the-cost" rel="noopener noreferrer"&gt;VentureBeat: Z.ai's GLM-5.2 beats GPT-5.5 on multiple benchmarks&lt;/a&gt; — SWE-bench Pro and FrontierSWE comparisons&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openrouter.ai/z-ai/glm-5.2" rel="noopener noreferrer"&gt;OpenRouter GLM-5.2 listing&lt;/a&gt; — live pricing and provider availability&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/zai-org/GLM-5.2" rel="noopener noreferrer"&gt;GLM-5.2 weights on Hugging Face&lt;/a&gt; — MIT-licensed open weights&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;GLM-5.2 is the strongest open-weights coding model available today. At 62.1 on SWE-bench Pro, it beats GPT-5.5 and closes much of the gap to Claude Opus 4.8's 69.2, while costing a fraction of either. The MIT license means you can self-host it, fine-tune it, and deploy it in air-gapped environments.&lt;/p&gt;

&lt;p&gt;The trade-offs are real. It's slow (75 seconds per task on OpenRouter), verbose (burning 43K tokens when 20K might suffice), and the 28.1% hallucination rate means you can't trust it for factual content without verification. The China data question adds a layer: if you can't self-host and your compliance posture rules out Chinese API endpoints, you're limited to OpenRouter's Western providers.&lt;/p&gt;

&lt;p&gt;For non-sensitive coding work where I'm paying out of pocket, GLM-5.2 through OpenRouter at $1.40/M input is the best value in frontier AI right now. It wrote clean Python and Go on the first pass, handled 400K tokens of context without degradation, and saved me about 80% compared to my usual Opus 4.8 API costs. Open-weights coding models have crossed from curiosity to credible daily driver.&lt;/p&gt;

</description>
      <category>glm52</category>
      <category>zai</category>
      <category>openweights</category>
      <category>aicoding</category>
    </item>
    <item>
      <title>LiteLLM Vulnerability: 6 CVEs, a Supply Chain Attack, and the Fixes</title>
      <dc:creator>Maksim Danilchenko</dc:creator>
      <pubDate>Wed, 17 Jun 2026 08:47:51 +0000</pubDate>
      <link>https://dev.to/dmaxdev/litellm-vulnerability-6-cves-a-supply-chain-attack-and-the-fixes-20cn</link>
      <guid>https://dev.to/dmaxdev/litellm-vulnerability-6-cves-a-supply-chain-attack-and-the-fixes-20cn</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;LiteLLM, the AI gateway proxy used by CrewAI, DSPy, Microsoft GraphRAG, and roughly 97 million monthly PyPI installs, has been hit by six CVEs and a full supply chain compromise since March 2026. The worst chain scores CVSS 10.0 and gives unauthenticated attackers remote code execution on any exposed LiteLLM proxy. CISA added one of the CVEs to its Known Exploited Vulnerabilities catalog on June 8 with a June 22 remediation deadline. If you run LiteLLM, upgrade to v1.83.14+ and rotate every credential the proxy can reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  What LiteLLM Does and Why You're Probably Running It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/BerriAI/litellm" rel="noopener noreferrer"&gt;LiteLLM&lt;/a&gt; is an open-source Python proxy that gives you a single OpenAI-compatible API in front of 100+ LLM providers: OpenAI, Anthropic, Google, Azure, Bedrock, Cohere, and more. You point your application at the proxy, the proxy routes requests to the right provider, and you get cost tracking, load balancing, rate limiting, and guardrails for free. About 50,300 GitHub stars and 97 million PyPI downloads a month as of June 2026.&lt;/p&gt;

&lt;p&gt;I ran a LiteLLM proxy for three months last year to route Claude and GPT calls from a multi-agent pipeline. The setup took about 15 minutes: a Docker container, a YAML config with my API keys, and a &lt;code&gt;litellm --config config.yaml&lt;/code&gt; command. That simplicity is exactly the problem. LiteLLM sits between your entire AI stack and every model provider you use. Every API key, every prompt, every response flows through it. When the proxy gets owned, the attacker gets everything — and AI tooling &lt;a href="https://www.danilchenko.dev/posts/ai-coding-tools-secret-leaks/" rel="noopener noreferrer"&gt;already leaks secrets at twice the rate of manual code&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And in 2026, LiteLLM got owned from every angle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Vulnerability at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CVE&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;CVSS&lt;/th&gt;
&lt;th&gt;Affected Versions&lt;/th&gt;
&lt;th&gt;Fixed In&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-42271&lt;/td&gt;
&lt;td&gt;Command injection (MCP endpoints)&lt;/td&gt;
&lt;td&gt;8.8 (10.0 chained)&lt;/td&gt;
&lt;td&gt;1.74.2 – 1.83.6&lt;/td&gt;
&lt;td&gt;v1.83.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-48710&lt;/td&gt;
&lt;td&gt;Starlette host header bypass&lt;/td&gt;
&lt;td&gt;Enabler&lt;/td&gt;
&lt;td&gt;Starlette ≤1.0.0&lt;/td&gt;
&lt;td&gt;Starlette 1.0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-42208&lt;/td&gt;
&lt;td&gt;Pre-auth SQL injection&lt;/td&gt;
&lt;td&gt;9.8&lt;/td&gt;
&lt;td&gt;1.81.16 – 1.83.6&lt;/td&gt;
&lt;td&gt;v1.83.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-47101&lt;/td&gt;
&lt;td&gt;Auth bypass (allowed_routes)&lt;/td&gt;
&lt;td&gt;8.8 (9.9 chained)&lt;/td&gt;
&lt;td&gt;&amp;lt;1.83.14&lt;/td&gt;
&lt;td&gt;v1.83.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-47102&lt;/td&gt;
&lt;td&gt;Privilege escalation (user_role)&lt;/td&gt;
&lt;td&gt;Part of 9.9 chain&lt;/td&gt;
&lt;td&gt;&amp;lt;1.83.14&lt;/td&gt;
&lt;td&gt;v1.83.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-40217&lt;/td&gt;
&lt;td&gt;Sandbox escape (exec guardrails)&lt;/td&gt;
&lt;td&gt;8.8&lt;/td&gt;
&lt;td&gt;&amp;lt;1.83.14&lt;/td&gt;
&lt;td&gt;v1.83.14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus a full supply chain attack on March 24 that injected credential-stealing malware into PyPI packages v1.82.7 and v1.82.8.&lt;/p&gt;

&lt;h2&gt;
  
  
  CVE-2026-42271: Remote Code Execution via MCP Test Endpoints
&lt;/h2&gt;

&lt;p&gt;This is the one &lt;a href="https://www.helpnetsecurity.com/2026/06/09/litellm-vulnerability-under-active-attack-cisa-warns-cve-2026-42271/" rel="noopener noreferrer"&gt;CISA flagged on June 8&lt;/a&gt; with a June 22 remediation deadline.&lt;/p&gt;

&lt;p&gt;LiteLLM versions 1.74.2 through 1.83.6 included two MCP server preview endpoints: &lt;code&gt;POST /mcp-rest/test/connection&lt;/code&gt; and &lt;code&gt;POST /mcp-rest/test/tools/list&lt;/code&gt;. These accepted a full server configuration (command, arguments, and environment variables for stdio transport) and LiteLLM spawned the supplied command as a subprocess on the host. Any authenticated user with a standard API key could reach them.&lt;/p&gt;

&lt;p&gt;That alone scores CVSS 8.8. But &lt;a href="https://horizon3.ai/attack-research/vulnerabilities/cve-2026-42271-chained-with-cve-2026-48710/" rel="noopener noreferrer"&gt;Horizon3.ai researchers&lt;/a&gt; chained it with CVE-2026-48710, a host header validation bypass in Starlette versions ≤1.0.0. The Starlette bug lets attackers bypass LiteLLM's authentication layer entirely by sending a malformed &lt;code&gt;Host&lt;/code&gt; header. No API key, no login, no prior access needed.&lt;/p&gt;

&lt;p&gt;The combined chain scores &lt;strong&gt;CVSS 10.0&lt;/strong&gt; and gives any internet-connected attacker unauthenticated RCE on the proxy host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# This is what the exploit looks like (simplified for illustration)&lt;/span&gt;
&lt;span class="c"&gt;# The attacker sends a crafted Host header to bypass auth,&lt;/span&gt;
&lt;span class="c"&gt;# then passes arbitrary commands via the MCP test endpoint&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://target-litellm:4000/mcp-rest/test/connection &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Host: badhost"&lt;/span&gt; &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;'{"server_name": "evil", "transport": "stdio",
       "command": "/bin/sh", "args": ["-c", "id; cat /proc/self/environ"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Horizon3.ai validated the full chain on June 1. CISA confirmed active exploitation in the wild.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Privilege Escalation Chain: Three CVEs, One Path to Root
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://thehackernews.com/2026/06/litellm-vulnerability-chain-lets-low.html" rel="noopener noreferrer"&gt;Obsidian Security disclosed this chain on June 11&lt;/a&gt;, and it's the scarier find because it starts from a default low-privilege account that every LiteLLM deployment has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — CVE-2026-47101 (Auth bypass):&lt;/strong&gt; A standard &lt;code&gt;internal_user&lt;/code&gt; calls &lt;code&gt;/key/generate&lt;/code&gt; with &lt;code&gt;allowed_routes: ["/*"]&lt;/code&gt;. LiteLLM validates that the &lt;code&gt;user_id&lt;/code&gt; matches the caller but never checks what routes are being granted. The user now holds a wildcard API key that bypasses all role-based access controls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — CVE-2026-47102 (Privilege escalation):&lt;/strong&gt; The wildcard key accesses &lt;code&gt;/user/update&lt;/code&gt;. This endpoint verifies identity but has zero field-level authorization. The attacker sets &lt;code&gt;user_role: "proxy_admin"&lt;/code&gt; on their own account and becomes a full admin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — CVE-2026-40217 (Sandbox escape to RCE):&lt;/strong&gt; Admin-level guardrail endpoints accept user-supplied Python code and compile it with &lt;code&gt;exec()&lt;/code&gt;. The globals dictionary omits &lt;code&gt;__builtins__&lt;/code&gt;, but Python automatically injects the full builtins module when that key is missing. One line gets you shell access:&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;# The "sandboxed" guardrail code that actually has full system access
&lt;/span&gt;&lt;span class="n"&gt;__builtins__&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__import__&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;os&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;system&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 /etc/shadow&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Obsidian rates the full chain CVSS 9.9. A server takeover exposes every provider key, every stored credential, every prompt and response passing through the gateway. They demonstrated a "man-in-the-gateway" attack that injected fake tool calls into Claude Code responses, causing it to execute attacker-controlled commands while evading auto-mode safety checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  CVE-2026-42208: SQL Injection in the Auth Layer
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.litellm.ai/blog/cve-2026-42208-litellm-proxy-sql-injection" rel="noopener noreferrer"&gt;Bishop Fox found this one&lt;/a&gt;: a pre-authentication SQL injection via the &lt;code&gt;Authorization: Bearer&lt;/code&gt; header. Versions 1.81.16 through 1.83.6 build the token verification query with a Python f-string:&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;# The vulnerable code in litellm/proxy/utils.py
&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&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;WHERE v.token = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LiteLLM's &lt;code&gt;_hash_token_if_needed()&lt;/code&gt; only hashes values starting with &lt;code&gt;sk-&lt;/code&gt;. Non-prefixed bearer tokens pass straight to the f-string. An attacker can extract the entire &lt;code&gt;LiteLLM_VerificationToken&lt;/code&gt; table — all virtual keys, team bindings, spend records — via timing-based blind injection with &lt;code&gt;pg_sleep()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Default deployments run with database superuser privileges, so the blast radius extends to every table in the database. &lt;a href="https://www.sysdig.com/blog/cve-2026-42208-targeted-sql-injection-against-litellms-authentication-path-discovered-36-hours-following-vulnerability-disclosure" rel="noopener noreferrer"&gt;Sysdig observed&lt;/a&gt; in-the-wild exploitation within 36 hours of the GitHub advisory going public, with attackers targeting &lt;code&gt;litellm_credentials.credential_values&lt;/code&gt; to steal upstream LLM provider keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The March Supply Chain Attack
&lt;/h2&gt;

&lt;p&gt;On March 24, a threat actor called TeamPCP published two backdoored versions of LiteLLM (v1.82.7 and v1.82.8) to &lt;a href="https://snyk.io/blog/poisoned-security-scanner-backdooring-litellm/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The attack chain started weeks earlier. TeamPCP exploited a &lt;code&gt;pull_request_target&lt;/code&gt; workflow vulnerability in Trivy (the container security scanner), hijacked the &lt;code&gt;aqua-bot&lt;/code&gt; GitHub Actions credentials, and used them to compromise Checkmarx KICS GitHub Actions. When LiteLLM's CI/CD pipeline ran the compromised Trivy scanner during a build, the malicious action extracted LiteLLM's &lt;code&gt;PYPI_PUBLISH&lt;/code&gt; token from the GitHub Actions environment. CI/CD supply chain risks like this are &lt;a href="https://www.danilchenko.dev/posts/github-ai-agents/" rel="noopener noreferrer"&gt;growing across the ecosystem&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.herodevs.com/blog-posts/the-litellm-supply-chain-attack-what-happened-why-it-matters-and-what-to-do-next" rel="noopener noreferrer"&gt;malicious payload&lt;/a&gt; installed a &lt;code&gt;.pth&lt;/code&gt; file that fired on every Python interpreter startup — no import required. It harvested environment variables, SSH keys, &lt;code&gt;.env&lt;/code&gt; files, API tokens, AWS/GCP/Azure credentials, Kubernetes configs, and database credentials. Everything was encrypted with AES-256-CBC and RSA-4096, bundled into a tarball, and exfiltrated to &lt;code&gt;models.litellm[.]cloud&lt;/code&gt;. It also deployed persistence via a fake &lt;code&gt;sysmon.service&lt;/code&gt; systemd unit and, in Kubernetes environments, spawned privileged pods on every node.&lt;/p&gt;

&lt;p&gt;PyPI quarantined the packages within about 3 hours. But Microsoft GraphRAG, Google ADK, DSPy, CrewAI, and OpenHands all pulled the malicious version as a transitive dependency during that window. &lt;a href="https://securitylabs.datadoghq.com/articles/litellm-compromised-pypi-teampcp-supply-chain-campaign/" rel="noopener noreferrer"&gt;Datadog Security Labs&lt;/a&gt; later linked this to a broader TeamPCP campaign that also hit npm packages and OpenVSX extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Check If You're Affected
&lt;/h2&gt;

&lt;p&gt;Run these checks in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Check your LiteLLM version&lt;/span&gt;
pip show litellm | &lt;span class="nb"&gt;grep &lt;/span&gt;Version
&lt;span class="c"&gt;# or inside Docker:&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; pip show litellm | &lt;span class="nb"&gt;grep &lt;/span&gt;Version

&lt;span class="c"&gt;# 2. Check your Starlette version (for the auth bypass chain)&lt;/span&gt;
pip show starlette | &lt;span class="nb"&gt;grep &lt;/span&gt;Version

&lt;span class="c"&gt;# 3. Check if the vulnerable MCP endpoints exist&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://localhost:4000/mcp-rest/test/connection
&lt;span class="c"&gt;# 404 = not vulnerable (endpoint removed). 405 or 200 = vulnerable.&lt;/span&gt;

&lt;span class="c"&gt;# 4. Check for supply chain attack artifacts&lt;/span&gt;
find / &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"litellm_init.pth"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.config/sysmon/sysmon.py 2&amp;gt;/dev/null
systemctl status sysmon.service 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your LiteLLM version is below 1.83.14, you're exposed to at least some of these vulnerabilities. If it's between 1.82.7 and 1.82.8 and you installed between March 24 10:39 UTC and ~13:38 UTC, you may have the supply chain backdoor.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Fix Everything
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upgrade LiteLLM to at least v1.83.14&lt;/strong&gt; (v1.89.1 is the latest as of June 16):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# pip upgrade&lt;/span&gt;
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; &lt;span class="s2"&gt;"litellm&amp;gt;=1.83.14"&lt;/span&gt;

&lt;span class="c"&gt;# Docker — pull the latest stable image&lt;/span&gt;
docker pull ghcr.io/berriai/litellm:main-latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Upgrade Starlette&lt;/strong&gt; to close the unauthenticated RCE path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; &lt;span class="s2"&gt;"starlette&amp;gt;=1.0.1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rotate credentials.&lt;/strong&gt; If your proxy was ever internet-accessible on a vulnerable version, treat every secret it could reach as compromised:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Rotate these (at minimum):&lt;/span&gt;
&lt;span class="c"&gt;# - LITELLM_MASTER_KEY and LITELLM_SALT_KEY&lt;/span&gt;
&lt;span class="c"&gt;# - DATABASE_URL and database passwords&lt;/span&gt;
&lt;span class="c"&gt;# - Every LLM provider API key (OpenAI, Anthropic, Google, Azure, Bedrock)&lt;/span&gt;
&lt;span class="c"&gt;# - MCP and agent OAuth tokens&lt;/span&gt;
&lt;span class="c"&gt;# - SSH keys and cloud credentials (AWS, GCP, Azure)&lt;/span&gt;

&lt;span class="c"&gt;# Check your logs for signs of exploitation:&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"/mcp-rest/test/(connection|tools/list)"&lt;/span&gt; /var/log/litellm/&lt;span class="k"&gt;*&lt;/span&gt;.log
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"/user/(update|bulk_update).*user_role"&lt;/span&gt; /var/log/litellm/&lt;span class="k"&gt;*&lt;/span&gt;.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you were hit by the supply chain attack, rebuild your containers from scratch. The backdoor persists through restarts via systemd and &lt;code&gt;.pth&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;Can't upgrade immediately? These temporary mitigations buy time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Block the vulnerable endpoints at your reverse proxy (nginx example)&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;^/mcp-rest/test/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Block non-standard bearer tokens to mitigate SQL injection&lt;/span&gt;
&lt;span class="c1"&gt;# Only allow tokens matching the sk- prefix pattern&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Went Wrong, and What It Means for Your AI Stack
&lt;/h2&gt;

&lt;p&gt;LiteLLM's security problems aren't unusual for a fast-growing open-source project. The f-string SQL query is a rookie mistake, but it's the kind of mistake that gets shipped when the priority is features, not security audits. The &lt;code&gt;exec()&lt;/code&gt; sandbox with missing builtins is a well-known Python gotcha that's been documented since 2009.&lt;/p&gt;

&lt;p&gt;The supply chain attack is worse. TeamPCP didn't need to find a bug in LiteLLM's code, and &lt;a href="https://www.danilchenko.dev/posts/claude-found-500-zero-days/" rel="noopener noreferrer"&gt;AI-driven vulnerability discovery&lt;/a&gt; means the attack surface is only growing. They compromised the security scanner that LiteLLM's CI pipeline trusted, then used that to steal PyPI publishing credentials. The attack took weeks to set up and targeted LiteLLM specifically because it sits at the chokepoint of thousands of AI deployments.&lt;/p&gt;

&lt;p&gt;If you're running any AI gateway or proxy, the takeaway is straightforward: treat it like you'd treat your database. Don't expose it to the internet without authentication. Don't run it as root. Pin your dependency versions (a &lt;a href="https://www.danilchenko.dev/posts/uv-vs-pip-vs-poetry/" rel="noopener noreferrer"&gt;good package manager&lt;/a&gt; helps), verify checksums on deployment, and have a credential rotation plan that doesn't require a Saturday fire drill.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.crewai.com/en/learn/litellm-removal-guide" rel="noopener noreferrer"&gt;CrewAI published a removal guide&lt;/a&gt; for decoupling from LiteLLM entirely. Whether that's the right move depends on your threat model. LiteLLM has since engaged Veria Labs for a proxy audit, launched a bug bounty ($500–$3,000), and committed to 5-business-day response times on advisories. The project is taking security more seriously. Whether that's enough depends on how many more CVEs are sitting in the other 100,000+ lines of Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is CVE-2026-42271?
&lt;/h3&gt;

&lt;p&gt;CVE-2026-42271 is a command injection vulnerability in LiteLLM's MCP REST test endpoints. Two preview endpoints (&lt;code&gt;/mcp-rest/test/connection&lt;/code&gt; and &lt;code&gt;/mcp-rest/test/tools/list&lt;/code&gt;) accepted full server configurations and spawned the supplied command as a subprocess. When chained with a Starlette host header bypass (CVE-2026-48710), it becomes an unauthenticated remote code execution vulnerability scoring CVSS 10.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  What versions of LiteLLM are affected?
&lt;/h3&gt;

&lt;p&gt;For the RCE and SQL injection vulnerabilities: versions 1.74.2 through 1.83.6. For the privilege escalation chain: all versions before 1.83.14. The supply chain attack only affected PyPI packages v1.82.7 and v1.82.8, published on March 24 and quarantined within 3 hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to fix LiteLLM vulnerability?
&lt;/h3&gt;

&lt;p&gt;Upgrade to LiteLLM v1.83.14 or later (v1.89.1 is the latest as of June 2026), upgrade Starlette to v1.0.1+, and rotate every credential the proxy had access to: LLM provider keys, database passwords, the master key, SSH keys, and cloud credentials.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is LiteLLM safe to use in production?
&lt;/h3&gt;

&lt;p&gt;After upgrading to v1.83.14+, the known vulnerabilities are patched. But LiteLLM has accumulated 16 CVEs across 2024–2026, including three Criticals. If you use it in production, run it behind a reverse proxy, restrict network access, disable unused features like Custom Code Guardrails, and monitor the &lt;a href="https://github.com/BerriAI/litellm/security/advisories" rel="noopener noreferrer"&gt;GitHub advisory feed&lt;/a&gt; closely.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened with the LiteLLM supply chain attack?
&lt;/h3&gt;

&lt;p&gt;On March 24, 2026, a threat actor called TeamPCP published backdoored LiteLLM versions 1.82.7 and 1.82.8 to PyPI. They got there by first compromising Trivy's GitHub Actions, then using that access to steal LiteLLM's PyPI publishing credentials from CI/CD. The malicious packages harvested every credential on the host machine and exfiltrated them to an attacker-controlled server. PyPI quarantined them within about 3 hours, but major frameworks like Microsoft GraphRAG and Google ADK pulled the malicious versions as transitive dependencies during that window.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does the LiteLLM exploit chain work?
&lt;/h3&gt;

&lt;p&gt;Multiple chains exist. The worst (CVSS 10.0) combines a command injection in MCP test endpoints with a Starlette host header bypass for unauthenticated RCE. A separate chain (CVSS 9.9) starts from a low-privilege default account: the attacker creates a wildcard API key via an unchecked &lt;code&gt;allowed_routes&lt;/code&gt; field, self-promotes to admin by modifying their own &lt;code&gt;user_role&lt;/code&gt;, then executes arbitrary Python through the Custom Code Guardrail's broken &lt;code&gt;exec()&lt;/code&gt; sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://horizon3.ai/attack-research/vulnerabilities/cve-2026-42271-chained-with-cve-2026-48710/" rel="noopener noreferrer"&gt;Horizon3.ai — CVE-2026-42271 chained with CVE-2026-48710&lt;/a&gt; — original research on the CVSS 10.0 RCE chain&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://thehackernews.com/2026/06/litellm-vulnerability-chain-lets-low.html" rel="noopener noreferrer"&gt;The Hacker News — LiteLLM Vulnerability Chain Lets Low-Privilege Users Take Over&lt;/a&gt; — coverage of the Obsidian Security CVSS 9.9 privilege escalation disclosure&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.helpnetsecurity.com/2026/06/09/litellm-vulnerability-under-active-attack-cisa-warns-cve-2026-42271/" rel="noopener noreferrer"&gt;Help Net Security — LiteLLM vulnerability under active attack, CISA warns&lt;/a&gt; — CISA KEV announcement&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.litellm.ai/blog/cve-2026-42208-litellm-proxy-sql-injection" rel="noopener noreferrer"&gt;Bishop Fox / LiteLLM — CVE-2026-42208 SQL Injection&lt;/a&gt; — official advisory for the pre-auth SQL injection&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.sysdig.com/blog/cve-2026-42208-targeted-sql-injection-against-litellms-authentication-path-discovered-36-hours-following-vulnerability-disclosure" rel="noopener noreferrer"&gt;Sysdig — CVE-2026-42208 exploitation analysis&lt;/a&gt; — in-the-wild exploitation within 36 hours&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://snyk.io/blog/poisoned-security-scanner-backdooring-litellm/" rel="noopener noreferrer"&gt;Snyk — How a Poisoned Security Scanner Became the Key to Backdooring LiteLLM&lt;/a&gt; — technical breakdown of the supply chain attack&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.herodevs.com/blog-posts/the-litellm-supply-chain-attack-what-happened-why-it-matters-and-what-to-do-next" rel="noopener noreferrer"&gt;HeroDevs — The LiteLLM Supply Chain Attack&lt;/a&gt; — timeline and impact assessment&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://securitylabs.datadoghq.com/articles/litellm-compromised-pypi-teampcp-supply-chain-campaign/" rel="noopener noreferrer"&gt;Datadog Security Labs — LiteLLM compromised PyPI TeamPCP campaign&lt;/a&gt; — broader TeamPCP campaign analysis&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.litellm.ai/blog/security-hardening-april-2026" rel="noopener noreferrer"&gt;LiteLLM — Security Hardening April 2026&lt;/a&gt; — official post-incident security improvements&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;LiteLLM got hammered from every direction in 2026: supply chain poisoning, unauthenticated RCE, SQL injection in the auth layer, privilege escalation from default accounts. If you've been running an internet-exposed proxy on anything older than v1.83.7, assume breach and act accordingly. The CISA deadline is June 22 — five days from now.&lt;/p&gt;

</description>
      <category>litellm</category>
      <category>aisecurity</category>
      <category>vulnerability</category>
      <category>python</category>
    </item>
  </channel>
</rss>
