<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: ai-assisted-programming</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/ai-assisted-programming.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-08-31T23:59:36+00:00</updated><author><name>Simon Willison</name></author><entry><title>Introducing wrapture</title><link href="https://simonwillison.net/2026/Aug/31/introducing-wrapture/" rel="alternate"/><published>2026-08-31T23:59:36+00:00</published><updated>2026-08-31T23:59:36+00:00</updated><id>https://simonwillison.net/2026/Aug/31/introducing-wrapture/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://grahamdumpleton.me/posts/2026/08/introducing-wrapture/"&gt;Introducing wrapture&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New from Graham Dumpleton (of &lt;a href="https://pypi.org/project/wrapt/"&gt;wrapt&lt;/a&gt;, mod_wsgi, and New Relic's Python agent fame), who describes Wrapture as taking the monkeypatching ideas from wrapt and extending them to apply to testing and tracing at the same time.&lt;/p&gt;
&lt;p&gt;Wrapture (&lt;a href="https://wrapture.readthedocs.io/"&gt;full documentation here&lt;/a&gt;) makes it easy to wrap any function or method such that all access can be traced, or can be overridden to return a different value.&lt;/p&gt;
&lt;p&gt;It acts as both an alternative to &lt;code&gt;unittest.mock&lt;/code&gt; and a way to implement tracing against an existing project:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Attaching observation to code you do not control, recording what flows through it, and doing so without disturbing the program being watched, is a problem I have never really stopped thinking about.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Wrapture includes &lt;a href="https://wrapture.readthedocs.io/en/latest/otel-export.html"&gt;OpenTelemetry support&lt;/a&gt; and even has an entirely configuration-based mechanism for adding tracing to an existing Python project, which looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;&lt;span class="pl-smi"&gt;capture&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;summary&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

[[&lt;span class="pl-en"&gt;observe&lt;/span&gt;]]
&lt;span class="pl-smi"&gt;target&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;domain:Calculator&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;name&lt;/span&gt; = [&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;outer&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;inner&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;]

[[&lt;span class="pl-en"&gt;sink&lt;/span&gt;]]
&lt;span class="pl-smi"&gt;type&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;jsonlines&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;path&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;trace.jsonl&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This is still a very young project - just a few weeks old - but it's off to a very promising start.&lt;/p&gt;
&lt;p&gt;Interestingly, this is also Graham's first attempt at  large entirely agent-driven project:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Every line of code and documentation in wrapture was written by an AI assistant working under my direction. I want to be upfront about that, and equally upfront about what it was not. This was not vibe coding, where a one-shot prompt produces a pile of generated code and the person driving hopes for the best because they lack the knowledge to judge what came back. Vibe coding has earned its bad reputation. I engineered wrapture carefully from the start. I have spent a long time in this particular corner of Python and knew exactly what the result needed to be, and the AI was the means of producing it rather than the source of the design.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In a follow-up post, &lt;a href="https://grahamdumpleton.me/posts/2026/09/unit-testing-with-wrapture/"&gt;Unit testing with wrapture&lt;/a&gt;, Graham shows the testing patterns supported by the new library:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;test_stub_with_wrapture&lt;/span&gt;():
    &lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;wrapture&lt;/span&gt;.&lt;span class="pl-c1"&gt;binding&lt;/span&gt;(
        &lt;span class="pl-v"&gt;Gateway&lt;/span&gt;, &lt;span class="pl-s"&gt;"charge"&lt;/span&gt;
    ).&lt;span class="pl-c1"&gt;on_call&lt;/span&gt;.&lt;span class="pl-c1"&gt;returns&lt;/span&gt;({
        &lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s"&gt;"stub"&lt;/span&gt;, &lt;span class="pl-s"&gt;"amount"&lt;/span&gt;: &lt;span class="pl-c1"&gt;0&lt;/span&gt;}
    ):
        &lt;span class="pl-k"&gt;assert&lt;/span&gt; &lt;span class="pl-en"&gt;OrderService&lt;/span&gt;().&lt;span class="pl-c1"&gt;place&lt;/span&gt;(
            &lt;span class="pl-c1"&gt;500&lt;/span&gt;
        )[&lt;span class="pl-s"&gt;"id"&lt;/span&gt;] &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"stub"&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;And this neat example of a test that calls and then modifies the return value from the original method:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;test_pinned_result_with_wrapture&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;charge&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;wrapture&lt;/span&gt;.&lt;span class="pl-c1"&gt;binding&lt;/span&gt;(
        &lt;span class="pl-v"&gt;Gateway&lt;/span&gt;, &lt;span class="pl-s"&gt;"charge"&lt;/span&gt;
    )
    &lt;span class="pl-s1"&gt;charge&lt;/span&gt;.&lt;span class="pl-c1"&gt;on_call&lt;/span&gt;.&lt;span class="pl-c1"&gt;transforms_result&lt;/span&gt;(
        &lt;span class="pl-k"&gt;lambda&lt;/span&gt; &lt;span class="pl-s1"&gt;r&lt;/span&gt;: {&lt;span class="pl-c1"&gt;**&lt;/span&gt;&lt;span class="pl-s1"&gt;r&lt;/span&gt;, &lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s"&gt;"ch_TEST"&lt;/span&gt;}
    )
    &lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;charge&lt;/span&gt;:
        &lt;span class="pl-k"&gt;assert&lt;/span&gt; &lt;span class="pl-en"&gt;OrderService&lt;/span&gt;().&lt;span class="pl-c1"&gt;place&lt;/span&gt;(
           &lt;span class="pl-c1"&gt;500&lt;/span&gt;
        ) &lt;span class="pl-c1"&gt;==&lt;/span&gt; {
            &lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s"&gt;"ch_TEST"&lt;/span&gt;, &lt;span class="pl-s"&gt;"amount"&lt;/span&gt;: &lt;span class="pl-c1"&gt;500&lt;/span&gt;
        }&lt;/pre&gt;

&lt;p&gt;(In both of these examples the &lt;code&gt;OrderService().place(...)&lt;/code&gt; method calls &lt;code&gt;Gateway().charge(...)&lt;/code&gt;.)


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/graham-dumpleton"&gt;graham-dumpleton&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/monkey-patching"&gt;monkey-patching&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pytest"&gt;pytest&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/observability"&gt;observability&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/opentelemetry"&gt;opentelemetry&lt;/a&gt;&lt;/p&gt;



</summary><category term="graham-dumpleton"/><category term="monkey-patching"/><category term="python"/><category term="testing"/><category term="pytest"/><category term="observability"/><category term="ai-assisted-programming"/><category term="agentic-engineering"/><category term="opentelemetry"/></entry><entry><title>Quoting Paul Dix</title><link href="https://simonwillison.net/2026/Aug/26/paul-dix/" rel="alternate"/><published>2026-08-26T08:07:55+00:00</published><updated>2026-08-26T08:07:55+00:00</updated><id>https://simonwillison.net/2026/Aug/26/paul-dix/</id><summary type="html">
    &lt;blockquote cite="https://pauldix.com/the-end-of-programming"&gt;&lt;p&gt;The fact that AI wrote 1M LOC and then refined it over the course of the next couple of months to produce a reliable piece of software that is currently running on millions of developer machines is absolutely mind blowing. And you can say, “well it’s not that impressive because they had an oracle to compare against, so it was simple to go from one language to another”, but I think that’s selling this entire thing short. If you can build a verification system and give proper direction, AI can produce a highly complex, highly sophisticated piece of software and it can continue to refine it until it just works.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://pauldix.com/the-end-of-programming"&gt;Paul Dix&lt;/a&gt;, The end of programming&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/bun"&gt;bun&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="bun"/><category term="ai"/><category term="llms"/></entry><entry><title>Quoting Linus Torvalds</title><link href="https://simonwillison.net/2026/Aug/22/linus-torvalds/" rel="alternate"/><published>2026-08-22T21:04:26+00:00</published><updated>2026-08-22T21:04:26+00:00</updated><id>https://simonwillison.net/2026/Aug/22/linus-torvalds/</id><summary type="html">
    &lt;blockquote cite="https://github.com/torvalds/linux/commit/818bebeb63dd6bf5f4e07e145f6cdbace520a34c"&gt;&lt;p&gt;And this was a debug session from hell, enormously helped by an AI doing much of the grunt-work.&lt;/p&gt;
&lt;p&gt;I'd like to call it my tireless helper, but the AI several times stated flat out that this was impossible and unsolvable and that we should just write a report about it.&lt;/p&gt;
&lt;p&gt;I suspect those things have been trained by people who may not be quite as stubborn as I am.&lt;/p&gt;
&lt;p&gt;But while the AI was ready to give up several times, it did keep adding debug code and analyzing it faithfully when I pushed. So credit where credit is due and I let the AI write the commit message above.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://github.com/torvalds/linux/commit/818bebeb63dd6bf5f4e07e145f6cdbace520a34c"&gt;Linus Torvalds&lt;/a&gt;, drm/xe: Don't hand out the flat CCS storage as usable VRAM&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/linus-torvalds"&gt;linus-torvalds&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/linux"&gt;linux&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="linus-torvalds"/><category term="linux"/><category term="generative-ai"/><category term="ai-assisted-programming"/><category term="ai"/><category term="llms"/></entry><entry><title>Quoting Florian Herrengt</title><link href="https://simonwillison.net/2026/Aug/12/florian-herrengt/" rel="alternate"/><published>2026-08-12T15:08:47+00:00</published><updated>2026-08-12T15:08:47+00:00</updated><id>https://simonwillison.net/2026/Aug/12/florian-herrengt/</id><summary type="html">
    &lt;blockquote cite="https://blog.florianherrengt.com/ai-removing-middle-class-software-engineering.html"&gt;&lt;p&gt;But then users start to report a weird bug. It's the 4th time your team has been trying to fix it. I mean... asking AI to fix it. Unfortunately, it seems like not even Fable can figure it out.&lt;/p&gt;
&lt;p&gt;You go talk to the person who worked on this feature.&lt;/p&gt;
&lt;p&gt;"So where does the data come from?"&lt;/p&gt;
&lt;p&gt;"Hmm... actually I don't know. Let me ask Claude."&lt;/p&gt;
&lt;p&gt;You sit next to each other watching an endless wall of text appear on the screen. Neither of you has any idea whether any of it is true but Claude seems very confident. [...]&lt;/p&gt;
&lt;p&gt;This project has become so convoluted, with so many layers and services, that no one on your team could possibly start to understand what's going on.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://blog.florianherrengt.com/ai-removing-middle-class-software-engineering.html"&gt;Florian Herrengt&lt;/a&gt;, AI is removing the middle class of software engineering&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai-misuse"&gt;ai-misuse&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cognitive-debt"&gt;cognitive-debt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai-misuse"/><category term="cognitive-debt"/><category term="generative-ai"/><category term="ai"/><category term="llms"/><category term="ai-assisted-programming"/></entry><entry><title>Devtools must be open source (exe.dev)</title><link href="https://simonwillison.net/2026/Aug/3/devtools-must-be-open-source-exedev/" rel="alternate"/><published>2026-08-03T15:30:38+00:00</published><updated>2026-08-03T15:30:38+00:00</updated><id>https://simonwillison.net/2026/Aug/3/devtools-must-be-open-source-exedev/</id><summary type="html">
    
        &lt;p&gt;&lt;a href="https://news.ycombinator.com/item?id=49156111#49156719"&gt;My comment&lt;/a&gt; on &lt;a href="https://news.ycombinator.com/item?id=49156111"&gt;Devtools must be open source (exe.dev)&lt;/a&gt; &amp;mdash; Hacker News.&lt;/p&gt;&lt;p&gt;One of the arguments for open source software for end-users has always been the freedom to examine and modify how that software works.&lt;/p&gt;
&lt;p&gt;The reality for most people - even expert programmers - has been that the freedom is more about being able to lean on &lt;em&gt;other people&lt;/em&gt; to do that. Most people can't justify the time commitment needed to read and then modify the code for tools they use very often.&lt;/p&gt;
&lt;p&gt;I think LLMs have changed that equation in a way that makes the original dream much more feasible.&lt;/p&gt;
&lt;p&gt;Several times a day I'll prompt regular Claude chat to "Clone x/y from GitHub and tell me how Z works".&lt;/p&gt;
&lt;p&gt;Getting software to compile in order to start hacking on it used to be enough friction that I often wouldn't bother. Now I treat that as a zero time investment challenge: tell Codex or Claude Code to checkout and build X and then come back ten minutes later and see how it got on.&lt;/p&gt;
&lt;p&gt;I'm not habitually &lt;em&gt;modifying&lt;/em&gt; the software I use yet, but I can see a path to that which didn't exist a year or so ago.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/hacker-news"&gt;hacker-news&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="hacker-news"/><category term="open-source"/><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/></entry><entry><title>Reverse-engineering is cheap now</title><link href="https://simonwillison.net/2026/Jul/20/cheap-reverse-engineering/" rel="alternate"/><published>2026-07-20T19:24:05+00:00</published><updated>2026-07-20T19:24:05+00:00</updated><id>https://simonwillison.net/2026/Jul/20/cheap-reverse-engineering/</id><summary type="html">
    &lt;p&gt;I keep hearing anecdotes from people who used coding agents to reverse-engineer and automate devices in their homes.&lt;/p&gt;
&lt;p&gt;I think this is an interesting illustration of the impact of the reduced cost of writing code.&lt;/p&gt;
&lt;p&gt;Prior to agents, it was entirely possible to reverse-engineer home devices. The problem was the ROI - was it really worth all of that effort? More importantly, any experienced programmer knows that undocumented, unstable APIs like that may well change or break in the future. Is that initial work worth the effort if you're committing yourself to a frustrating cycle of maintenance in the future?&lt;/p&gt;
&lt;p&gt;Coding agents change that equation entirely. The effort to get a simple automation working has dropped, as has the cost of trying and failing to get it to work. Since the code is so cheap, the idea of having to maintain it in the future - or throw it away and start again - carries way less psychological baggage.&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/reverse-engineering"&gt;reverse-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;&lt;/p&gt;



</summary><category term="reverse-engineering"/><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="coding-agents"/></entry><entry><title>Firefox in WebAssembly</title><link href="https://simonwillison.net/2026/Jul/16/firefox-in-webassembly/" rel="alternate"/><published>2026-07-16T23:34:16+00:00</published><updated>2026-07-16T23:34:16+00:00</updated><id>https://simonwillison.net/2026/Jul/16/firefox-in-webassembly/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://developer.puter.com/labs/firefox-wasm/"&gt;Firefox in WebAssembly&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This is absurdly cool: Puter compiled Firefox to WebAssembly such that the whole browser runs in another browser.&lt;/p&gt;
&lt;p&gt;Here's my blog, running in Firefox, running in WebAssembly, running in Chrome:&lt;/p&gt;
&lt;p&gt;&lt;img alt="A Chrome window. The tab has the Firefox UI and has loaded my blog. On the right is the Chrome network panel showing that it loaded resources that include a 233MB gecko.wasm and an 18MB chrome-assets.tar.zst" src="https://static.simonwillison.net/static/2026/firefox-wasm.webp" /&gt;&lt;/p&gt;
&lt;p&gt;They chose Firefox/Gecko because it has strong single-process support. The project used an estimated $25,000 worth of Claude Opus and Fable tokens, but took advantage of a Claude Max subscription plan so cost much less in actual dollars.&lt;/p&gt;
&lt;p&gt;The demo funnels all traffic over a WebSocket protocol (using the &lt;a href="https://github.com/MercuryWorkshop/wisp-protocol"&gt;Wisp protocol&lt;/a&gt;) through Puter's server - a requirement to get this kind of thing to work because code running in browsers can't open arbitrary network connections.&lt;/p&gt;
&lt;p&gt;(That proxying sounds expensive! The team &lt;a href="https://news.ycombinator.com/item?id=48926939#48936563"&gt;had to scale the servers up&lt;/a&gt; to handle the traffic during the Hacker News conversation about the project.)&lt;/p&gt;
&lt;p&gt;Puter claim this supports end-to-end encryption and that looks to be true - I inspected the WebSocket messages and traffic to my own HTTPS site was encrypted whereas requests and responses to &lt;code&gt;http://www.example.com/&lt;/code&gt; were in cleartext.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/HeyPuter/firefox-wasm"&gt;Here's the repo&lt;/a&gt; for &lt;code&gt;firefox-wasm&lt;/code&gt;. &lt;a href="https://github.com/theogbob/WebkitWasm"&gt;theogbob/WebkitWasm&lt;/a&gt; is a similar project that compiles WebKit to WASM, but that one doesn't currently have an accessible online demo.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=48926939"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/browsers"&gt;browsers&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/firefox"&gt;firefox&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-mythos-fable"&gt;claude-mythos-fable&lt;/a&gt;&lt;/p&gt;



</summary><category term="browsers"/><category term="firefox"/><category term="ai"/><category term="webassembly"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude"/><category term="claude-mythos-fable"/></entry><entry><title>Quoting Armin Ronacher</title><link href="https://simonwillison.net/2026/Jul/14/armin-ronacher/" rel="alternate"/><published>2026-07-14T18:04:23+00:00</published><updated>2026-07-14T18:04:23+00:00</updated><id>https://simonwillison.net/2026/Jul/14/armin-ronacher/</id><summary type="html">
    &lt;blockquote cite="https://lucumr.pocoo.org/2026/7/13/the-tower-keeps-rising/"&gt;&lt;p&gt;The shared language of a software project is not English or Python but it is the common understanding of what its concepts mean, where the boundaries are, which invariants matter, who owns what, and why the system has the shape it does. This language is rarely written down in one place. It lives partly in documentation and code, but also in code review, conversations, arguments, and the experience of having to explain a change to somebody else.&lt;/p&gt;
&lt;p&gt;Before agents, some of this shared understanding was maintained by friction. If I wanted to change your storage layer, I usually had to read your code, ask you questions, and perhaps coordinate with another team whose service depended on it. This was slow, and much of that slowness was waste but not all of it was. Some of it was the process by which your understanding became mine, and by which both of us discovered whether we still agreed about how the system worked. This friction synchronizes people.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://lucumr.pocoo.org/2026/7/13/the-tower-keeps-rising/"&gt;Armin Ronacher&lt;/a&gt;, The Tower Keeps Rising&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/software-engineering"&gt;software-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/armin-ronacher"&gt;armin-ronacher&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai"/><category term="software-engineering"/><category term="llms"/><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="armin-ronacher"/><category term="agentic-engineering"/></entry><entry><title>DOOMQL</title><link href="https://simonwillison.net/2026/Jul/13/doomql/" rel="alternate"/><published>2026-07-13T22:34:41+00:00</published><updated>2026-07-13T22:34:41+00:00</updated><id>https://simonwillison.net/2026/Jul/13/doomql/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/petergpt/doomql"&gt;DOOMQL&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Peter Gostev built this using GPT-5.6 Sol. This is a &lt;em&gt;lot&lt;/em&gt; of fun: &lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;DOOMQL started with a deliberately unreasonable question: what if SQLite were the game engine, not merely the place where a game stores data?&lt;/p&gt;
&lt;p&gt;The result is a small, original Doom-like game in which SQL owns movement, collision, enemies, combat, progression and every RGB pixel on screen.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It's implemented as a Python terminal script - I tried it out like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /tmp
git clone https://github.com/petergpt/doomql
cd doomql
uv run host/doomql.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img alt="Screenshot of a macOS terminal window titled &amp;quot;doomql — python3.14 ◂ uv run host/doomql.py — 134×31&amp;quot; showing a retro Doom-style game rendered as text-mode pixel art. The scene is a pixelated first-person corridor with gray paneled walls, dark red doors on the far left and right, a floating cyan-and-gold coin pickup on the right side, a white crosshair near the center, and a dark weapon barrel rising from the bottom center. A status bar below the scene reads &amp;quot;HP 100/100 AMMO 037 SCORE 00225 INDEX MISSING TICK 0028450&amp;quot;, followed by an orange line &amp;quot;FIND THE INDEX TOKEN&amp;quot; and a cyan controls line &amp;quot;WASD MOVE J/L OR ARROWS TURN SPACE FIRE E USE P PAUSE CTRL-C EXIT&amp;quot;." src="https://static.simonwillison.net/static/2026/doomql-window.png" /&gt;&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/petergpt/doomql/blob/main/sql/003_render.sql"&gt;the huge SQL query&lt;/a&gt; that implements a full ray tracer in SQLite using a recursive CTE.&lt;/p&gt;
&lt;p&gt;Running the above script creates a &lt;code&gt;/tmp/doomql/.doomql/doomql.sqlite&lt;/code&gt; SQLite database, which you can explore using Datasette like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx --prerelease=allow  --with datasette-apps datasette \
  /tmp/doomql/.doomql/doomql.sqlite \
  -p 4444 --root --secret 1 --internal internal.db
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--with datasette-apps&lt;/code&gt; option installs the new &lt;a href="https://simonwillison.net/2026/Jun/18/datasette-apps/"&gt;Datasette Apps&lt;/a&gt; plugin, which supports creating custom HTML+JavaScript apps that can run SQL queries directly within the Datasette interface.&lt;/p&gt;
&lt;p&gt;I created a new app, pasted the copy-paste prompt into Claude chat (Fable 5) &lt;a href="https://claude.ai/share/c793280c-2ef1-4555-a7c2-31281abfdf78"&gt;and told it&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Build an app that displays the current state of the screen using the frame_pixels view with its x, y, r, g, b columns. have it refresh once a second.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This got me a working HTML+JavaScript app inside Datasette that could reflect the current state while I played the game in my terminal. Then I added:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;add a minimap&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And now my Datasette App looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a dark-themed web app running a retro Doom-style game rendered from SQL queries. The page header reads &amp;quot;DOOMQL&amp;quot; with buttons &amp;quot;All apps&amp;quot;, &amp;quot;Edit app&amp;quot;, &amp;quot;Pin&amp;quot;, and &amp;quot;Full screen&amp;quot;. Inside the game panel, the title &amp;quot;DOOMQL&amp;quot; sits above the subtitle &amp;quot;auto-refreshing once a second · frame and tactical map straight from SQL&amp;quot;. The left side shows a pixelated first-person corridor view with gray walls, dark red doors, a floating cyan-and-gold coin pickup, a white crosshair, and a weapon barrel at bottom center. A status bar below reads &amp;quot;HP 100/100 AMMO 037 SCORE 00225 INDEX MISSING TICK 0027847&amp;quot;. On the right, a panel titled &amp;quot;TACTICAL MAP&amp;quot; shows a top-down grid map with a player triangle, a red enemy circle, yellow pickup dots, red wall markers, and a green exit square, with a legend reading &amp;quot;you&amp;quot;, &amp;quot;enemy&amp;quot;, &amp;quot;pickup&amp;quot;, &amp;quot;locked door&amp;quot;, &amp;quot;door&amp;quot;, &amp;quot;exit&amp;quot;. Below the game view, an orange banner reads &amp;quot;FIND THE INDEX TOKEN&amp;quot;, followed by the cyan line &amp;quot;READ-ONLY VIEWER · SELECT x, y, r, g, b FROM frame_pixels&amp;quot;. At the bottom, a green &amp;quot;RUNNING&amp;quot; badge appears beside the stats &amp;quot;160×54 · 8,640 pixels · 3 hostiles · query 89 ms · refreshing every 1 s&amp;quot;." src="https://static.simonwillison.net/static/2026/doomql-datasette-app.png" /&gt;&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/7c78184476fccd4b70b02f7f9048dffa"&gt;the HTML app code&lt;/a&gt; - paste that into your own Datasette instance (using the &lt;code&gt;uvx --with datasette-apps&lt;/code&gt; recipe from above) to try it yourself.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/petergostev/status/2076692164310884468"&gt;@petergostev&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/games"&gt;games&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt"&gt;gpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-apps"&gt;datasette-apps&lt;/a&gt;&lt;/p&gt;



</summary><category term="games"/><category term="sql"/><category term="sqlite"/><category term="ai"/><category term="datasette"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="gpt"/><category term="datasette-apps"/></entry><entry><title>datasette code-frequency chart on GitHub</title><link href="https://simonwillison.net/2026/Jul/13/datasette-code-frequency/" rel="alternate"/><published>2026-07-13T21:45:27+00:00</published><updated>2026-07-13T21:45:27+00:00</updated><id>https://simonwillison.net/2026/Jul/13/datasette-code-frequency/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette/graphs/code-frequency"&gt;datasette code-frequency chart on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Out of curiosity I decided to see if I could find a useful illustration of the impact of coding agents and Opus 4.5 class models on my own output. The best I've found so far is this GitHub chart of frequency of code changes to my &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; open source project:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a GitHub &amp;quot;Code frequency&amp;quot; bar chart, subtitled &amp;quot;Additions and deletions per week&amp;quot;, showing green addition bars and red deletion bars per week from 2018 through 2026, with a y-axis labeled Frequency ranging from -20k to 30k. Activity comes in sporadic bursts: the largest spike is 37,022 additions with -9,528 deletions in 2026, followed by 14,638 additions with -6,584 deletions in late 2025, 15,998 additions in early 2018, and a standout deletion spike of -10,658 in mid-2020, with quieter periods of smaller weekly changes in between." src="https://static.simonwillison.net/static/2026/datasette-code-frequency.png" /&gt;&lt;/p&gt;
&lt;p&gt;The big spike in activity at the end aligns with Opus 4.8, GPT-5.5, Fable 5 and GPT-5.6 Sol.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="ai"/><category term="datasette"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="coding-agents"/></entry><entry><title>sqlite-utils 4.1</title><link href="https://simonwillison.net/2026/Jul/11/sqlite-utils/" rel="alternate"/><published>2026-07-11T23:50:20+00:00</published><updated>2026-07-11T23:50:20+00:00</updated><id>https://simonwillison.net/2026/Jul/11/sqlite-utils/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/4.1"&gt;sqlite-utils 4.1&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;The first dot-release since &lt;a href="https://simonwillison.net/2026/Jul/7/sqlite-utils-4/"&gt;4.0 a few days ago&lt;/a&gt;, introducing a number of minor new features.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils insert&lt;/code&gt; and &lt;code&gt;sqlite-utils upsert&lt;/code&gt; now accept a &lt;code&gt;--code&lt;/code&gt; option for &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#cli-insert-code"&gt;providing a block of Python code&lt;/a&gt; (or a path to a &lt;code&gt;.py&lt;/code&gt; file) that defines a &lt;code&gt;rows()&lt;/code&gt; function or &lt;code&gt;rows&lt;/code&gt; iterable of rows to insert, as an alternative to importing from a file. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/684"&gt;#684&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; already had features that allow you to pass blocks of Python code as CLI arguments, for example &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#converting-data-in-columns"&gt;this one&lt;/a&gt; for the &lt;code&gt;sqlite-utils convert&lt;/code&gt; command:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;sqlite&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-s1"&gt;utils&lt;/span&gt; &lt;span class="pl-s1"&gt;convert&lt;/span&gt; &lt;span class="pl-s1"&gt;content&lt;/span&gt;.&lt;span class="pl-c1"&gt;db&lt;/span&gt; &lt;span class="pl-s1"&gt;articles&lt;/span&gt; &lt;span class="pl-s1"&gt;headline&lt;/span&gt; '
&lt;span class="pl-s1"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;convert&lt;/span&gt;(&lt;span class="pl-s1"&gt;value&lt;/span&gt;):
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;value&lt;/span&gt;.&lt;span class="pl-c1"&gt;upper&lt;/span&gt;()'&lt;/pre&gt;
&lt;p&gt;Allowing blocks of code to &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-rows-generated-by-python-code"&gt;generate new rows directly&lt;/a&gt; was on obvious extension of that pattern:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;sqlite&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-s1"&gt;utils&lt;/span&gt; &lt;span class="pl-s1"&gt;insert&lt;/span&gt; &lt;span class="pl-s1"&gt;data&lt;/span&gt;.&lt;span class="pl-c1"&gt;db&lt;/span&gt; &lt;span class="pl-s1"&gt;creatures&lt;/span&gt; &lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-s1"&gt;code&lt;/span&gt; '
&lt;span class="pl-s1"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;rows&lt;/span&gt;():
    &lt;span class="pl-k"&gt;yield&lt;/span&gt; {&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Cleo"&lt;/span&gt;}
    &lt;span class="pl-k"&gt;yield&lt;/span&gt; {&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Suna"&lt;/span&gt;}
' &lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-c1"&gt;-&lt;/span&gt;&lt;span class="pl-s1"&gt;pk&lt;/span&gt; &lt;span class="pl-s1"&gt;id&lt;/span&gt;&lt;/pre&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils insert&lt;/code&gt; and &lt;code&gt;sqlite-utils upsert&lt;/code&gt; now accept &lt;code&gt;--type column-name type&lt;/code&gt; to &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#cli-insert-csv-tsv-column-types"&gt;override the type automatically chosen when the table is created&lt;/a&gt;. This is useful for CSV or TSV columns such as ZIP codes that look like integers but should be stored as &lt;code&gt;TEXT&lt;/code&gt; to preserve leading zeros. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/131"&gt;#131&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;A long-standing feature request which turned out to be a &lt;a href="https://github.com/SAY-5/sqlite-utils/commit/d2ac3765ed9f0516bb0cbc2508a5c3907fb6a71a"&gt;simple implementation&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;table.drop_index(name)&lt;/code&gt; method and &lt;code&gt;sqlite-utils drop-index&lt;/code&gt; command for dropping an index by name. Both accept &lt;code&gt;ignore=True&lt;/code&gt;/&lt;code&gt;--ignore&lt;/code&gt; to ignore a missing index. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/626"&gt;#626&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils query&lt;/code&gt; can now read the SQL query from standard input by passing &lt;code&gt;-&lt;/code&gt; in place of the query, for example &lt;code&gt;echo "select * from dogs" | sqlite-utils query dogs.db -&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/765"&gt;#765&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Two more small features. I had Codex review all open issues and highlight the easiest ones!&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils upsert&lt;/code&gt; can now infer the primary key of an existing table, so &lt;code&gt;--pk&lt;/code&gt; can be omitted when upserting into a table that already has a primary key.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Another Codex suggestion, an obvious missing CLI feature from a Python library improvement that shipped in the 4.0 release.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;table.transform()&lt;/code&gt; and &lt;code&gt;table.transform_sql()&lt;/code&gt; now accept &lt;code&gt;strict=True&lt;/code&gt; or &lt;code&gt;strict=False&lt;/code&gt; to change a table’s &lt;a href="https://www.sqlite.org/stricttables.html"&gt;SQLite strict mode&lt;/a&gt;. Omitting the option preserves the existing mode. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/787"&gt;#787&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;sqlite-utils transform&lt;/code&gt; command now accepts &lt;code&gt;--strict&lt;/code&gt; and &lt;code&gt;--no-strict&lt;/code&gt; to change a table’s strict mode. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/787"&gt;#787&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;These two were inspired by &lt;a href="https://evanhahn.com/prefer-strict-tables-in-sqlite/"&gt;Prefer STRICT tables in SQLite&lt;/a&gt; by Evan Hahn, which did the rounds &lt;a href="https://news.ycombinator.com/item?id=48873940"&gt;on Hacker News&lt;/a&gt; today. Evan pointed out that:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Unfortunately, I don’t think there’s a way to ALTER a table to make it strict. I think you have to copy the data out of the non-strict table into the strict one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's exactly what the &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#transforming-a-table"&gt;sqlite-utils transform mechanism&lt;/a&gt; does, so I extended it to add the ability to switch tables from strict to non-strict and vice-versa.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/ab8256b81646ad967a601975e206de64"&gt;the GPT-5.6 Sol xhigh Codex transcript&lt;/a&gt; I used to implement those new strict table features. One of the most useful prompts I ran was this one:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;use uv run python -c and manually exercise the new .transform(strict=) option, see if you can find any edge-cases or bugs&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Effectively telling the model to manually test its work, outside of the automated tests it had already written. This turned up two minor issues that we then fixed.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="python"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="ai-assisted-programming"/></entry><entry><title>Rewriting Bun in Rust</title><link href="https://simonwillison.net/2026/Jul/8/rewriting-bun-in-rust/" rel="alternate"/><published>2026-07-08T23:57:21+00:00</published><updated>2026-07-08T23:57:21+00:00</updated><id>https://simonwillison.net/2026/Jul/8/rewriting-bun-in-rust/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://bun.com/blog/bun-in-rust"&gt;Rewriting Bun in Rust&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Jarred Sumner has been promising this blog post (&lt;a href="https://x.com/jarredsumner/status/2053063524826620129"&gt;since May 9th&lt;/a&gt;) about his Zig to Rust rewrite of Bun for significantly longer than it took him to finish the rewrite.&lt;/p&gt;
&lt;p&gt;Honestly, it was worth the wait. This is a detailed description of an extremely sophisticated piece of agentic engineering, featuring dynamic workflows, trial runs, adversarial review and all sorts of other interesting tricks.&lt;/p&gt;
&lt;p&gt;Jarred spends the first half of the post praising Zig for getting Bun this far. Then we get to a core idea in the piece, emphasis mine:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Our bugfix list felt bad and I was tired of going to sleep worrying about crashes in Bun. I don't blame Zig for that - other users of Zig don't have the bugs we had, and mixing GC with manually-managed memory is an uncommon enough thing for software to need that no language really designs for it. We wouldn't have gotten this far if not for Zig, and I'll always be grateful. &lt;strong&gt;Until very recently, programming language choice was a one-way decision for a project like Bun.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Everyone knows you should never stop the world and rewrite a large piece of software from the ground up. Joel Spolsky highlighted that in &lt;a href="https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/"&gt;Things You Should Never Do, Part I&lt;/a&gt; back in April 2000!&lt;/p&gt;
&lt;p&gt;Coding agents powered by today's frontier models change that equation.&lt;/p&gt;
&lt;p&gt;Why pick Rust? It all came down to those challenges with memory management:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A large percentage of bugs from that list are use-after-free, double-free, and "forgot to free" in an error path. In safe Rust, these are compiler errors and RAII-like automatic cleanup with &lt;code&gt;Drop&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A crucial enabling factor for the rewrite was that the Bun test suite was written in TypeScript, which meant it could act as &lt;a href="https://simonwillison.net/tags/conformance-suites/"&gt;a conformance suite&lt;/a&gt;. This allowed an agent harness to automate much of the initial port from Bun to Rust, initially as an experiment to try out an earlier version of the model we now have access to as Mythos/Fable.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;At first, I didn't expect it to work. A few days in, a high % of the test suite started passing and I saw how much the new Rust code matched up with the original Zig codebase. My opinion went from "this is worth trying" to "I'm going to merge this". [...]&lt;/p&gt;
&lt;p&gt;For most of those 11 days (and after), I monitored workflows - manually reading the outputs to check for issues and bugs, and prompting Claude to edit the loop to fix things.&lt;/p&gt;
&lt;p&gt;How do you review a PR with +1 million lines added? How do you start to build the confidence needed to responsibly merge large quantities of LLM-authored code?&lt;/p&gt;
&lt;p&gt;A language-independent test suite with a million assertions, adversarial code review and when something does go wrong, fixing the process that generates the code instead of hand-fixing the code.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The new implementation of Bun has been live in Claude Code for nearly a month now:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Claude Code v2.1.181 (released June 17th) and later use the Rust port of Bun. Startup got 10% faster on Linux but otherwise, barely anyone noticed. Boring is good.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A perk of working at Anthropic is that you don't have to pay for your tokens - handy when the estimated cost is $165,000!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Pre-merge, this took 5.9 billion uncached input tokens, 690 million output tokens, and 72 billion cached input token reads — around $165,000 at API pricing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This whole thing is a fascinating case study in taking on wildly ambitious projects with the help of coordinated parallel agents.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=48837877"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rust"&gt;rust&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/zig"&gt;zig&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/bun"&gt;bun&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/conformance-suites"&gt;conformance-suites&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-mythos-fable"&gt;claude-mythos-fable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/jarred-sumner"&gt;jarred-sumner&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai"/><category term="rust"/><category term="zig"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="anthropic"/><category term="bun"/><category term="conformance-suites"/><category term="agentic-engineering"/><category term="claude-mythos-fable"/><category term="jarred-sumner"/></entry><entry><title>Quoting Kenton Varda</title><link href="https://simonwillison.net/2026/Jul/8/kenton-varda/" rel="alternate"/><published>2026-07-08T20:03:34+00:00</published><updated>2026-07-08T20:03:34+00:00</updated><id>https://simonwillison.net/2026/Jul/8/kenton-varda/</id><summary type="html">
    &lt;blockquote cite="https://twitter.com/kentonvarda/status/2074924213983740233"&gt;&lt;p&gt;I just declared a moratorium against AI-written change descriptions (e.g. PR and commit messages, also issues/tickets) from my team.&lt;/p&gt;
&lt;p&gt;AI was writing change descriptions that were worse than useless to me as I tried to review PRs: outlining details of the code that could easily be seen by looking at the code, but omitting the higher-level framing needed to understand broadly what the code is doing.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://twitter.com/kentonvarda/status/2074924213983740233"&gt;Kenton Varda&lt;/a&gt;&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/kenton-varda"&gt;kenton-varda&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="kenton-varda"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="ai"/><category term="llms"/></entry><entry><title>sqlite-utils 4.0, now with database schema migrations</title><link href="https://simonwillison.net/2026/Jul/7/sqlite-utils-4/" rel="alternate"/><published>2026-07-07T19:32:57+00:00</published><updated>2026-07-07T19:32:57+00:00</updated><id>https://simonwillison.net/2026/Jul/7/sqlite-utils-4/</id><summary type="html">
    &lt;p&gt;This morning I released &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0"&gt;sqlite-utils 4.0&lt;/a&gt;, the 124th release of that project and the first major version bump since &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-0"&gt;3.0&lt;/a&gt; in November 2020. In addition to some small but significant breaking changes (described in &lt;a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html"&gt;this upgrade guide&lt;/a&gt;), this version introduces three major features: &lt;strong&gt;database migrations&lt;/strong&gt;, &lt;strong&gt;nested transactions&lt;/strong&gt; (via a new &lt;code&gt;db.atomic()&lt;/code&gt; method), and support for &lt;strong&gt;compound foreign keys&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id="database-schema-migrations-using-sqlite-utils"&gt;Database schema migrations using sqlite-utils&lt;/h4&gt;
&lt;p&gt;Schema migrations define a sequence of changes to be made to a SQLite database, plus a mechanism for tracking which migrations have been applied and applying any that are found to be pending.&lt;/p&gt;
&lt;p&gt;Migrations are defined in Python files using the &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html"&gt;sqlite-utils Python library&lt;/a&gt;, which includes a powerful &lt;code&gt;table.transform()&lt;/code&gt; method providing &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#transforming-a-table"&gt;enhanced alter table capabilities&lt;/a&gt; that are not supported by SQLite's &lt;code&gt;ALTER TABLE&lt;/code&gt; statement.&lt;/p&gt;
&lt;p&gt;(&lt;code&gt;table.transform()&lt;/code&gt; implements the pattern &lt;a href="https://www.sqlite.org/lang_altertable.html#otheralter"&gt;recommended by the SQLite documentation&lt;/a&gt; - create a new temporary table with the new schema, copy across the data, then drop the old table and rename the temporary one in its place.)&lt;/p&gt;
&lt;p&gt;Here's an example migration file which creates a table called &lt;code&gt;creatures&lt;/code&gt;, adds an additional column to it in a second step, then changes the types of two of the columns in a third:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Migrations&lt;/span&gt;

&lt;span class="pl-s1"&gt;migrations&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Migrations&lt;/span&gt;(&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;)

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;create_table&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;create&lt;/span&gt;(
        {&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s1"&gt;int&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;, &lt;span class="pl-s"&gt;"species"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;},
        &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;,
    )

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;add_weight&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;add_column&lt;/span&gt;(&lt;span class="pl-s"&gt;"weight"&lt;/span&gt;, &lt;span class="pl-s1"&gt;float&lt;/span&gt;)

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;change_column_types&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;transform&lt;/span&gt;(&lt;span class="pl-s1"&gt;types&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;{&lt;span class="pl-s"&gt;"species"&lt;/span&gt;: &lt;span class="pl-s1"&gt;int&lt;/span&gt;, &lt;span class="pl-s"&gt;"weight"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;})&lt;/pre&gt;
&lt;p&gt;Save that as &lt;code&gt;migrations.py&lt;/code&gt; and run it against a fresh database like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx sqlite-utils migrate data.db migrations.py&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then if you check the schema of that database:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx sqlite-utils schema data.db&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You'll see this SQL:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;CREATE&lt;/span&gt; &lt;span class="pl-k"&gt;TABLE&lt;/span&gt; "&lt;span class="pl-en"&gt;_sqlite_migrations&lt;/span&gt;" (
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;PRIMARY KEY&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;migration_set&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;applied_at&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;
);
&lt;span class="pl-k"&gt;CREATE&lt;/span&gt; &lt;span class="pl-k"&gt;UNIQUE INDEX&lt;/span&gt; "&lt;span class="pl-en"&gt;idx__sqlite_migrations_migration_set_name&lt;/span&gt;"
    &lt;span class="pl-k"&gt;ON&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;_sqlite_migrations&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; (&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;migration_set&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;);
&lt;span class="pl-k"&gt;CREATE&lt;/span&gt; &lt;span class="pl-k"&gt;TABLE&lt;/span&gt; "&lt;span class="pl-en"&gt;creatures&lt;/span&gt;" (
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt; &lt;span class="pl-k"&gt;PRIMARY KEY&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;species&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;,
   &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;weight&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;
);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;_sqlite_migrations&lt;/code&gt; table is used to keep track of which migration functions have been run. The &lt;code&gt;creatures&lt;/code&gt; table above is the schema after all three migrations have been applied.&lt;/p&gt;
&lt;p&gt;To see a list of migrations, both pending and applied, run this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx sqlite-utils migrate data.db migrations.py --list&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Migrations for: creatures

  Applied:
    create_table - 2026-07-07 17:58:41.360051+00:00
    add_weight - 2026-07-07 17:58:41.360608+00:00
    change_column_types - 2026-07-07 18:01:15.802000+00:00

  Pending:
    (none)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you don't specify a migrations file, the &lt;code&gt;sqlite-utils migrate data.db&lt;/code&gt; command will scan the current directory and its subdirectories for files called &lt;code&gt;migrations.py&lt;/code&gt; and apply any &lt;code&gt;Migrations()&lt;/code&gt; instances it finds in them.&lt;/p&gt;
&lt;p&gt;You can also execute migrations &lt;a href="https://sqlite-utils.datasette.io/en/stable/migrations.html#applying-migrations-in-python"&gt;from Python code&lt;/a&gt; using the &lt;code&gt;migrations.apply(db)&lt;/code&gt; method, which is useful for building tools that manage their own database schemas over multiple versions. My own &lt;a href="https://llm.datasette.io/"&gt;LLM tool&lt;/a&gt; has been using a version of this pattern for several years now, as shown in &lt;a href="https://github.com/simonw/llm/blob/0.31/llm/embeddings_migrations.py"&gt;llm/embeddings_migrations.py&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="prior-art"&gt;Prior art&lt;/h4&gt;
&lt;p&gt;My favorite implementation of this pattern remains &lt;a href="https://docs.djangoproject.com/en/6.0/topics/migrations/"&gt;Django's Migrations&lt;/a&gt;, developed by Andrew Godwin based on his earlier project &lt;a href="https://github.com/andrewgodwin/south"&gt;South&lt;/a&gt;. Fun fact: Andrew, Russ Keith-Magee, and I presented our competing approaches to schema migrations for Django on the &lt;a href="https://www.youtube.com/watch?v=VSq8m00p1FM"&gt;Schema Evolution panel&lt;/a&gt; at the very first DjangoCon back in 2008! My attempt was called &lt;a href="https://simonwillison.net/2008/Sep/3/dmigrations/"&gt;dmigrations&lt;/a&gt;, developed with a team at Global Radio in London.&lt;/p&gt;
&lt;p&gt;Django's migrations can be automatically generated from model definitions and include the ability to roll back to a previous version. The &lt;code&gt;sqlite-utils&lt;/code&gt; approach is deliberately simpler: unlike Django, &lt;code&gt;sqlite-utils&lt;/code&gt; encourages programmatic table creation rather than a model definition ORM, so there isn't anything we can use to automatically generate migrations.&lt;/p&gt;
&lt;p&gt;I decided to skip rollback, since in my experience it's a feature that is rarely used. With a SQLite project, an easy way to achieve rollback is to create a copy of your database file before you apply the migrations!&lt;/p&gt;
&lt;h4 id="migrating-from-sqlite-migrate"&gt;Migrating from sqlite-migrate&lt;/h4&gt;
&lt;p&gt;The design of &lt;code&gt;sqlite-utils&lt;/code&gt; migrations is three years old now - I had originally released it as a separate package called &lt;a href="https://github.com/simonw/sqlite-migrate"&gt;sqlite-migrate&lt;/a&gt;, which never quite graduated beyond a beta release.&lt;/p&gt;
&lt;p&gt;I've used that package in enough places now that I'm confident in the design, so I've decided to promote it to a feature of &lt;code&gt;sqlite-utils&lt;/code&gt; to make it available by default to all of the other tools in the growing sqlite-utils/Datasette/LLM ecosystem.&lt;/p&gt;
&lt;p&gt;I made &lt;a href="https://github.com/simonw/sqlite-migrate/releases/tag/0.2"&gt;one last release&lt;/a&gt; of &lt;code&gt;sqlite-migrate&lt;/code&gt;, which switches it to depend on &lt;code&gt;sqlite-utils&amp;gt;=4&lt;/code&gt; and replaces the &lt;code&gt;__init__.py&lt;/code&gt; file with the following:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Migrations&lt;/span&gt;

&lt;span class="pl-s1"&gt;__all__&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [&lt;span class="pl-s"&gt;"Migrations"&lt;/span&gt;]&lt;/pre&gt;
&lt;p&gt;Any existing project that depends on &lt;code&gt;sqlite-migrate&lt;/code&gt; should continue to work without alterations.&lt;/p&gt;
&lt;h4 id="everything-else-in-sqlite-utils-4-0"&gt;Everything else in sqlite-utils 4.0&lt;/h4&gt;
&lt;p&gt;Here are the release notes for this version, with some inline annotations:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The 4.0 release includes some minor backwards-incompatible fixes (hence the major version number bump) and introduces three major new features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sqlite-utils.datasette.io/en/stable/migrations.html#migrations"&gt;Database migrations&lt;/a&gt;, providing a structured mechanism for evolving a project’s schema over time. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/752"&gt;#752&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I think of migrations as the signature new feature, hence this blog post.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-atomic"&gt;Nested transaction support&lt;/a&gt; via &lt;code&gt;db.atomic()&lt;/code&gt;, plus numerous improvements to how transactions work across the library. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/755"&gt;#755&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; has long had a confused relationship with database transactions, partly because when I started designing the library back in 2018 I didn't yet have a great feel for how those worked in SQLite itself.&lt;/p&gt;
&lt;p&gt;Adding migrations to the core library made me determined to finally crack this nut, since transactions make migration systems a whole lot safer and easier to reason about.&lt;/p&gt;
&lt;p&gt;I ended up building this around a &lt;code&gt;db.atomic()&lt;/code&gt; context manager which looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;atomic&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Cleo"&lt;/span&gt;}, &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Pancakes"&lt;/span&gt;})&lt;/pre&gt;
&lt;p&gt;SQLite supports &lt;a href="https://sqlite.org/lang_savepoint.html"&gt;Savepoints&lt;/a&gt;, and as a result &lt;code&gt;db.atomic()&lt;/code&gt; can be nested to carry out transactions inside of transactions. It's pretty neat!&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Support for &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-compound-foreign-keys"&gt;compound foreign keys&lt;/a&gt;, including creation, transformation and introspection through &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-introspection-foreign-keys"&gt;table.foreign_keys&lt;/a&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/594"&gt;#594&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This came about when I asked a coding agent to review all open issues and PRs for things that should be included in a 4.0 release since they would represent breaking changes if I added them later, and it correctly identified that compound foreign keys were exactly that kind of feature.&lt;/p&gt;
&lt;p&gt;I started with a breaking change to the &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#python-api-introspection-foreign-keys"&gt;table.foreign_keys&lt;/a&gt; introspection method, and then decided to see if Claude Fable 5 could handle the more fiddly job of integrating compound foreign key &lt;em&gt;creation&lt;/em&gt; into the library. The API design it helped create felt &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#compound-foreign-keys"&gt;exactly right to me&lt;/a&gt; - consistent with how the rest of the library worked already.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Other notable changes include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Upserts now use SQLite’s &lt;code&gt;INSERT ... ON CONFLICT ... DO UPDATE SET&lt;/code&gt; syntax, detect existing table primary keys automatically and reject records that are missing required primary key values. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/652"&gt;#652&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This was the change that first pushed me to consider a breaking-change 4.0 version bump. I built this to help support &lt;a href="https://github.com/simonw/sqlite-chronicle"&gt;sqlite-chronicle&lt;/a&gt;, which uses triggers to keep track of rows in a table that have been inserted, updated or deleted.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;db.query()&lt;/code&gt; now executes immediately and rejects statements that do not return rows; use &lt;code&gt;db.execute()&lt;/code&gt; for writes and DDL.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Probably the &lt;a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html#python-api-changes"&gt;most disruptive breaking change&lt;/a&gt; - I've had to update a few places in my own code to switch from &lt;code&gt;db.query()&lt;/code&gt; to &lt;code&gt;db.execute()&lt;/code&gt; as a result.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;CSV and TSV imports now detect column types by default, while inserts into existing tables preserve those tables’ column types. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/679"&gt;#679&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;code&gt;sqlite-utils insert data.db creatures creatures.csv --detect-types&lt;/code&gt; flag was a later addition to allow column types (text, integer, real) to be automatically detected based on the data in a CSV. It should be the default, and releasing a 4.0 means I can make it so.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;table.extract()&lt;/code&gt; and &lt;code&gt;extracts=&lt;/code&gt; no longer create lookup table records for all-&lt;code&gt;null&lt;/code&gt; values. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/186"&gt;#186&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The oldest issue addressed by this release - the underlying bug was opened (by me) in October 2020.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;See &lt;a href="https://sqlite-utils.datasette.io/en/stable/upgrading.html#upgrading-3-to-4"&gt;Upgrading from 3.x to 4.0&lt;/a&gt; for details on backwards-incompatible changes.&lt;/p&gt;
&lt;p&gt;The detailed release notes for the features and fixes shipped during the 4.0 pre-release cycle are available in &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0a0"&gt;4.0a0&lt;/a&gt;, &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0a1"&gt;4.0a1&lt;/a&gt;, &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc1"&gt;4.0rc1&lt;/a&gt;, &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc2"&gt;4.0rc2&lt;/a&gt;, &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc3"&gt;4.0rc3&lt;/a&gt; and &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v4-0rc4"&gt;4.0rc4&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The upgrade guide was entirely written by Claude Fable 5, Claude Opus 4.8 and GPT-5.5. The same is true of the release notes.&lt;/p&gt;
&lt;p&gt;This is the kind of documentation I've slowly become comfortable outsourcing to the robots. It doesn't need to convince people of anything, or express any opinions - its job is to be as accurate and detailed as possible. I've reviewed the release notes closely and can confirm they are accurate and comprehensive.&lt;/p&gt;
&lt;h4 id="claude-fable-5-helped-a-lot"&gt;Claude Fable 5 helped a lot&lt;/h4&gt;
&lt;p&gt;I released the first alpha of sqlite-utils 4.0 &lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#a0-2025-05-08"&gt;over a year ago&lt;/a&gt;. I've been dragging my heels on the stable release because of the amount of work it would take to track down and clean up the many other minor design flaws that a major version number allowed me to take on.&lt;/p&gt;
&lt;p&gt;Assistance from Claude Fable 5 (and to a lesser extent Opus 4.8 and GPT-5.5) gave me just the boost I needed to overcome inertia and make the most of the time I could afford to spend on this library.&lt;/p&gt;
&lt;p&gt;Fable has &lt;em&gt;really good taste&lt;/em&gt; in API design, and is &lt;a href="https://simonwillison.net/2026/Jun/11/fable-is-relentlessly-proactive/"&gt;relentlessly proactive&lt;/a&gt; if you give it a more open goal. My most successful prompt was a review task that I issued against what I thought was the last release candidate:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;review the changes on main since the last tagged 3.x release - I am about to ship them as sqlite-utils 4.0, a stable version that promises no backwards-incompatible fixes for a very long time.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;review the changelog and upgrade guide, and write yourself scratch scripts to try out all of the new features in v4 - save those scripts but don't commit them&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I tried this with GPT-5.5 xhigh in Codex Desktop and Fable 5 in Claude Code.&lt;/p&gt;
&lt;p&gt;GPT-5.5 &lt;a href="https://gist.github.com/simonw/823fdecc031371d56dce39537adc0096"&gt;wrote 5 Python scripts&lt;/a&gt; and didn't turn up anything particularly interesting - its &lt;a href="https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4899982463"&gt;final report is here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Fable 5 &lt;a href="https://gist.github.com/simonw/95800bf584f8e437f1cf0d48d9ef81e6"&gt;wrote 12 scripts&lt;/a&gt;, identified 4 release blockers and 10 additional issues &lt;a href="https://github.com/simonw/sqlite-utils/issues/769#issuecomment-4900034150"&gt;in its report&lt;/a&gt;, and built a neat &lt;a href="https://gist.githubusercontent.com/simonw/95800bf584f8e437f1cf0d48d9ef81e6/raw/c43918b36a129bba1d2f2a129117aa11c85146c0/12_bug_repros.py"&gt;combined repro script&lt;/a&gt;, which, when run, output the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;=== 1. Failed db.execute() write leaves an implicit transaction open ===
  in_transaction after failed write: True
  BUG: table 'other' silently lost when connection closed

=== 2. Leading ';' bypasses the query() first-token scanner ===
  BUG: raised OperationalError: no such savepoint: sqlite_utils_query
  BUG: row persisted despite rollback (count=1)

=== 3. Rejected write PRAGMA via query() still takes effect ===
  BUG: user_version=5 after 'rejected' statement (docs say no effect)

=== 4. Implicit compound FK resolves pk columns in table order, not PK order ===
  BUG: other_columns reported as ('b', 'a'), should be ('a', 'b')
  BUG: transform of valid data raised IntegrityError: FOREIGN KEY constraint failed

=== 5. ForeignKey (now a dataclass) is no longer hashable ===
  BUG: cannot use 'sqlite_utils.db.ForeignKey' as a set element (unhashable type: 'ForeignKey')

=== 6. Mixed ForeignKey objects and tuples in foreign_keys= rejected ===
  BUG: foreign_keys= should be a list of tuples

=== 7. insert --csv into an EXISTING table transforms its column types ===
  BUG: existing zip '01234' is now 1234 (column type: int)

=== 8. insert(pk=, alter=True) regression: InvalidColumns before alter runs ===
  BUG: InvalidColumns: Invalid primary key column ['id'] for table t with columns ['a']

=== 9. migrate --stop-before an already-applied migration applies everything ===
  BUG: m2 was applied despite --stop-before m1 (m1 already applied)

=== 10. ensure_autocommit_on() silently commits an open transaction ===
  BUG: row survived rollback (count=1) - transaction was committed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I found myself agreeing with almost all of them. Here's &lt;a href="https://github.com/simonw/sqlite-utils/pull/779"&gt;the PR with 16 commits&lt;/a&gt; where we worked through them in turn.&lt;/p&gt;
&lt;p&gt;There's no doubt in my mind that sqlite-utils 4.0 is a significantly higher-quality release than if I had built it without the assistance of the latest frontier models.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/schema-migrations"&gt;schema-migrations&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-mythos-fable"&gt;claude-mythos-fable&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="schema-migrations"/><category term="projects"/><category term="sqlite"/><category term="ai"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="anthropic"/><category term="claude"/><category term="agentic-engineering"/><category term="claude-mythos-fable"/></entry><entry><title>simonw/browser-compat-db</title><link href="https://simonwillison.net/2026/Jun/24/browser-compat-db/" rel="alternate"/><published>2026-06-24T23:59:03+00:00</published><updated>2026-06-24T23:59:03+00:00</updated><id>https://simonwillison.net/2026/Jun/24/browser-compat-db/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/browser-compat-db"&gt;simonw/browser-compat-db&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Inspired by Mozilla's &lt;a href="https://developer.mozilla.org/en-US/blog/introducing-mdn-mcp-server/"&gt;new MDN MCP service&lt;/a&gt; - &lt;a href="https://github.com/mdn/mcp"&gt;source code here&lt;/a&gt; - I decided to try converting their comprehensive &lt;a href="https://github.com/mdn/browser-compat-data"&gt;mdn/browser-compat-data&lt;/a&gt; repository full of browser compatibility data into a SQLite database.&lt;/p&gt;
&lt;p&gt;This new GitHub repo includes a Claude Code for web (Opus 4.8) &lt;a href="https://github.com/simonw/browser-compat-db/blob/main/build_db.py"&gt;generated script&lt;/a&gt; for doing that using &lt;a href="https://github.com/simonw/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I wanted the resulting ~66MB SQLite database to be available via the GitHub CDN with open CORS headers. GitHub releases don't have those, but any file stored in a regular GitHub repository does - so I had Codex Desktop (GPT-5.5) build &lt;a href="https://github.com/simonw/browser-compat-db/blob/main/.github/workflows/build-db.yml"&gt;a GitHub Actions workflow&lt;/a&gt; that builds the database and then force-pushes it to a &lt;code&gt;db&lt;/code&gt; "orphan" branch.&lt;/p&gt;
&lt;p&gt;You can download the resulting database &lt;a href="https://github.com/simonw/browser-compat-db/blob/db/browser-compat.db"&gt;from here&lt;/a&gt;, and since it's hosted with open CORS headers you can also &lt;a href="https://lite.datasette.io/?url=https://github.com/simonw/browser-compat-db/blob/db/browser-compat.db#/browser-compat/releases_tree"&gt;explore it with Datasette Lite&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mozilla"&gt;mozilla&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/model-context-protocol"&gt;model-context-protocol&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mdn"&gt;mdn&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="mozilla"/><category term="projects"/><category term="github-actions"/><category term="datasette-lite"/><category term="ai-assisted-programming"/><category term="model-context-protocol"/><category term="mdn"/></entry><entry><title>Datasette Apps: Host custom HTML applications inside Datasette</title><link href="https://simonwillison.net/2026/Jun/18/datasette-apps/" rel="alternate"/><published>2026-06-18T23:58:38+00:00</published><updated>2026-06-18T23:58:38+00:00</updated><id>https://simonwillison.net/2026/Jun/18/datasette-apps/</id><summary type="html">
    &lt;p&gt;Today we launched a new plugin for Datasette, &lt;a href="https://github.com/datasette/datasette-apps"&gt;datasette-apps&lt;/a&gt;, with &lt;a href="https://datasette.io/blog/2026/datasette-apps/"&gt;this launch announcement post&lt;/a&gt; on the Datasette project blog. That post has the &lt;em&gt;what&lt;/em&gt;, but I'm going to expand on that a little bit here to provide the &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;
&lt;h4 id="the-tl-dr"&gt;The TL;DR&lt;/h4&gt;
&lt;p&gt;Datasette Apps are self-contained HTML+JavaScript applications that run in a tightly constrained &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; sandbox hosted on your Datasette application. They can use JavaScript to run read-only SQL queries against data in Datasette, and can run write queries too if you configure them &lt;a href="https://datasette.io/blog/2026/sql-write-queries/"&gt;with some stored queries&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here's a &lt;a href="https://agent.datasette.io/-/apps/01kvdp1d26g8trye3r4gc3yy9c"&gt;very simple example&lt;/a&gt; and a &lt;a href="https://agent.datasette.io/-/apps/01ktvyaejhk07zskdx2tewxppe"&gt;more complex custom timeline example&lt;/a&gt; - the latter looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/datasette-timeline-app.jpg" alt="Screenshot of a web app titled &amp;quot;Datasette timeline&amp;quot; with &amp;quot;All apps&amp;quot;, &amp;quot;Edit app&amp;quot;, and &amp;quot;Pin&amp;quot; buttons top-right and a &amp;quot;Full screen&amp;quot; button below them. Inside a bordered panel, the heading &amp;quot;Datasette timeline&amp;quot; sits above a search box reading &amp;quot;Search news, blog posts and releases…&amp;quot; with three checked checkboxes labeled News, Blog, and Releases. Below, text reads &amp;quot;Showing 200 of 1,953 items&amp;quot;, followed by a scrollable list of timeline entries. Each entry has a colored tag (blue &amp;quot;BLOG&amp;quot; or green &amp;quot;RELEASE&amp;quot;), a date, a blue linked title, and a paragraph of description. The visible entries are a &amp;quot;BLOG&amp;quot; post dated 2026-06-11 titled &amp;quot;Datasette 1.0a33 with JSON extras in the API&amp;quot;, a &amp;quot;RELEASE&amp;quot; dated 2026-06-11 titled &amp;quot;datasette 1.0a33&amp;quot;, and a &amp;quot;RELEASE&amp;quot; dated 2026-06-09 titled &amp;quot;llm 0.32a3&amp;quot;, each with body text and a &amp;quot;▶ Show more&amp;quot; toggle. A separate panel at the bottom shows a collapsed &amp;quot;▶ 2 log entries&amp;quot; toggle." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Apps are allowed to run JavaScript and render HTML and CSS. They are limited in terms of access - the &lt;code&gt;&amp;lt;iframe sandbox="allow-scripts allow-forms"&amp;gt;&lt;/code&gt; they run in prevents them from accessing cookies or localStorage and they also have an injected CSP header (thanks to &lt;a href="https://simonwillison.net/2026/Apr/3/test-csp-iframe-escape/"&gt;this research&lt;/a&gt;) which prevents them from making HTTP requests to outside hosts, preventing a malicious or buggy app from exfiltrating private data.&lt;/p&gt;
&lt;p&gt;Datasette Apps started out as my attempt at building a Claude Artifacts mechanism for &lt;a href="https://datasette.io/blog/2026/datasette-agent/"&gt;Datasette Agent&lt;/a&gt;, but I quickly realised that the sandboxed pattern is interesting for way more than just adding custom apps in a chat interface and promoted it to its own top-level concept within the Datasette ecosystem.&lt;/p&gt;
&lt;p&gt;They're also a fun way to turn my &lt;a href="https://tools.simonwillison.net/"&gt;multi-year experiment in vibe-coded HTML tools&lt;/a&gt; into a core feature of my main project!&lt;/p&gt;
&lt;p&gt;You can try out Datasette Apps by signing in with GitHub to the &lt;a href="https://agent.datasette.io/"&gt;agent.datasette.io&lt;/a&gt; demo instance.&lt;/p&gt;
&lt;h4 id="why-build-this-"&gt;Why build this?&lt;/h4&gt;
&lt;p&gt;Since the very first release, Datasette has offered a flexible backend for creating custom HTML apps via its JSON API.&lt;/p&gt;
&lt;p&gt;One of my earliest Datasette projects was an internal search engine for documentation when I worked at Eventbrite - it worked by importing documents from different systems into SQLite on a cron and then serving them through a Datasette instance with a custom HTML+JavaScript search interface that directly queried the Datasette API.&lt;/p&gt;
&lt;p&gt;I had client-side JavaScript constructing SQL queries, which originally was intended as an engineering joke but turned out to be a &lt;em&gt;really productive&lt;/em&gt; way of iterating on the app!&lt;/p&gt;
&lt;p&gt;That project, combined with my experience &lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/"&gt;building my HTML tools collection&lt;/a&gt; and my &lt;a href="https://simonwillison.net/2024/Oct/21/claude-artifacts/"&gt;experiments with Claude Artifacts&lt;/a&gt;, has convinced me that adding a Datasette-style backend to a self-contained HTML frontend is an astonishingly powerful combination.&lt;/p&gt;
&lt;p&gt;Imagine how much more useful Claude Artifacts could be if they had access to a persistent relational database. That's what I'm building with Datasette Apps!&lt;/p&gt;
&lt;h4 id="neat-ideas-in-datasette-apps"&gt;Neat ideas in Datasette Apps&lt;/h4&gt;
&lt;p&gt;Here are a few of the ideas and patterns I've figured out building this which I think have staying power.&lt;/p&gt;
&lt;h5 id="iframe-sandbox-allow"&gt;
&lt;code&gt;&amp;lt;iframe sandbox="allow-scripts" srcdoc="..."&amp;gt;&lt;/code&gt; + &lt;code&gt;&amp;lt;meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:;"&amp;gt;&lt;/code&gt;
&lt;/h5&gt;
&lt;p&gt;This is the magic combination that makes Datasette Apps feasible in the first place. I need to run untrusted HTML and JavaScript on a highly sensitive domain - an authenticated Datasette instance can contain all sorts of private data. The &lt;code&gt;sandbox=&lt;/code&gt; attribute lets me run that untrusted code in a way that cannot interact with the parent application - it can't read the DOM, or access cookies, or steal secrets from &lt;code&gt;localStorage&lt;/code&gt;. It can however use &lt;code&gt;fetch()&lt;/code&gt; and friends to load content (or exfiltrate data) from other domains. But... it turns out if you &lt;em&gt;start&lt;/em&gt; an HTML page with a &lt;code&gt;&amp;lt;meta http-equiv="Content-Security-Policy"&amp;gt;&lt;/code&gt; header you can &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP"&gt;set additional policies&lt;/a&gt; that lock down access to other domains. I was worried that malicious JavaScript would be able to update or remove that header but it turns out &lt;a href="https://github.com/simonw/research/tree/main/test-csp-iframe-escape#readme"&gt;that doesn't work&lt;/a&gt; - once set, the CSP policy is immutable for the content of that frame.&lt;/p&gt;
&lt;h5 id="locked-down-apis-with-postmessage-and-messagechannel-"&gt;Locked down APIs with &lt;code&gt;postMessage()&lt;/code&gt; and &lt;code&gt;MessageChannel()&lt;/code&gt;
&lt;/h5&gt;
&lt;p&gt;Having locked down those iframes to the point that they couldn't do anything interesting at all, the challenge was to open them back again such that they could run an allow-list of operations, starting with read-only SQL queries against specified databases.&lt;/p&gt;
&lt;p&gt;I built the first version of this with &lt;code&gt;postMessage()&lt;/code&gt;, which allows a child iframe to send messages to the parent window. I created a simple protocol for requesting that the parent run a SQL query - the parent could then verify it was against an allow-listed database before executing it.&lt;/p&gt;
&lt;p&gt;One of the LLM tools, I think it was GPT-5.5, suggested that &lt;code&gt;postMessage()&lt;/code&gt; on its own can be exploited if the iframe somehow loads additional code from an untrusted domain. I don't think that applies to Datasette Apps, but I also believe in defense in depth, so I &lt;a href="https://gist.github.com/simonw/0b29f301c2007808314eb04675c66916"&gt;had GPT-5.5 help me&lt;/a&gt; port to a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel"&gt;MessageChannel()&lt;/a&gt; based transport instead.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MessageChannel()&lt;/code&gt; has the advantage that if a page navigates to somewhere else the channel closes automatically, removing any chance of executing commands sent from an untrusted external page.&lt;/p&gt;
&lt;h5 id="visible-logs-for-queries-and-errors"&gt;Visible logs, for queries and errors&lt;/h5&gt;
&lt;p&gt;If you navigate to &lt;a href="https://agent.datasette.io/-/apps/01ktvyaejhk07zskdx2tewxppe"&gt;the timeline demo&lt;/a&gt; and search for the string &lt;code&gt;usercontent&lt;/code&gt; you'll pull in some search results that embed images from the &lt;code&gt;user-images.githubusercontent.com&lt;/code&gt; domain. This domain is not in the CSP allow-list, so it trips an error.&lt;/p&gt;
&lt;p&gt;Those errors are captured and transmitted back to the parent frame, where they can be displayed in a useful error log. This is meant to make hacking on apps more productive by surfacing otherwise-invisible problems.&lt;/p&gt;
&lt;p&gt;I built &lt;a href="https://simonwillison.net/2026/May/13/csp-allow/"&gt;an experiment&lt;/a&gt; demonstrating that you can even turn this into a one-click-to-allow mechanism for building the CSP allow-list based on what breaks, but I haven't integrated that idea into &lt;code&gt;datasette-apps&lt;/code&gt; just yet.&lt;/p&gt;
&lt;p&gt;SQL queries are also visibly logged - scroll to the &lt;a href="https://agent.datasette.io/-/apps/01ktvyaejhk07zskdx2tewxppe"&gt;bottom of the timeline page&lt;/a&gt; to see that in action.&lt;/p&gt;
&lt;h5 id="stored-queries-for-write-operations"&gt;Stored queries for write operations&lt;/h5&gt;
&lt;p&gt;I want apps to be able to conditionally write to the database, but this is an &lt;em&gt;even more&lt;/em&gt; dangerous proposition than SQL reads!&lt;/p&gt;
&lt;p&gt;My solution involves Datasette's &lt;a href="https://docs.datasette.io/en/latest/sql_queries.html#stored-queries"&gt;stored queries&lt;/a&gt; feature, rebranded from "canned queries" and given a major upgrade &lt;a href="https://datasette.io/blog/2026/sql-write-queries/"&gt;in the recent Datasette 1.0a31&lt;/a&gt; - work that was directly inspired by Datasette Apps.&lt;/p&gt;
&lt;p&gt;Users can create a stored write query that performs an insert or update, then allow-list that specific query for an app to use. Usage from code inside an app looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;result&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;storedQuery&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"todos"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;"add_todo"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c1"&gt;title&lt;/span&gt;: &lt;span class="pl-s"&gt;"Buy milk"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;due_date&lt;/span&gt;: &lt;span class="pl-s"&gt;"2026-06-20"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;priority&lt;/span&gt;: &lt;span class="pl-s"&gt;"high"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;completed&lt;/span&gt;: &lt;span class="pl-c1"&gt;false&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I'm only just beginning to explore the possibilities this unlocks myself, but my goal is to support full read-write applications built safely as Datasette Apps.&lt;/p&gt;
&lt;h5 id="copy-and-paste-a-prompt-to-build-an-app"&gt;Copy and paste a prompt to build an app&lt;/h5&gt;
&lt;p&gt;The Datasette Apps plugin has no dependency on LLMs at all, but these self-contained apps are the perfect shape to be written by a modern LLM.&lt;/p&gt;
&lt;p&gt;The create app form includes a copyable prompt at the end. This prompt has everything a model needs to know to build a new app, including the schema of any selected databases.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://datasette.io/static/blog/2026/create-app-prompt.jpg" alt="Screenshot of the lower part of a &amp;quot;Create app&amp;quot; page. At the top is the tail end of an HTML code editor (lines 35–43, closing the script, body, and html tags) and a blue &amp;quot;Create app&amp;quot; button. Below is a section headed &amp;quot;Use AI to build this app&amp;quot; with the text &amp;quot;Describe the app you want in an LLM chat, then copy this prompt in as context so it can generate or revise the app HTML. Paste the result into the HTML editor above.&amp;quot; A blue &amp;quot;Copy prompt&amp;quot; button sits above a &amp;quot;▼ Show full prompt&amp;quot; toggle. An expanded text box shows the prompt: &amp;quot;Build a Datasette HTML app. App name: Latest news. Return a complete single-file HTML document. Include &amp;lt;DOCTYPE, CSS, and JavaScript in the same file. This app will run inside a sandboxed iframe protected by a strict Content Security Policy. Important limitations: – Direct network access is disabled by default. – The app cannot fetch from Datasette, localhost, or arbitrary origins. – External fetch() requests only work for exact https:// origins explicitly granted in the app's network access settings. – Remote images are allowed from those same exact https:// origins. Local file previews using data: and blob: image URLs are allowed. – External script tags are allowed from those same exact https:// origins. – External stylesheet links and style elements are allowed from those same exact https:// origins. – history.replaceState(), history.pushState(), history.back(), history.forward(), and history.go() are no-ops in the sandbox. – CORS still applies even when an origin is granted. Use this API for data access: – await datasette.query(database, sql, params?)&amp;quot;" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This means you can click "copy", paste it into ChatGPT or Claude or Gemini, tell it what you need, and there's a good chance the model will spit out the code necessary to build the app.&lt;/p&gt;
&lt;p&gt;If you have &lt;a href="https://agent.datasette.io/"&gt;Datasette Agent&lt;/a&gt; installed your AI assistant will also gain tools to both create new apps and edit existing ones, Claude Artifacts style.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://datasette.io/static/blog/2026/create-app-agent.jpg" alt="Screenshot of a &amp;quot;Chat&amp;quot; interface with a &amp;quot;← Back&amp;quot; link top-left and an &amp;quot;EXPORT&amp;quot; button top-right. A blue user message bubble reads &amp;quot;Build an app showing the 5 most recent headlines from the blog_posts table&amp;quot;. Below are two collapsed toggles: &amp;quot;► Tool: describe_table&amp;quot; and &amp;quot;► Result: describe_table&amp;quot;. A thinking line reads &amp;quot;Thinking: …will transition to creating the application using app_create as the next step.&amp;quot; A section headed &amp;quot;Querying Latest Posts&amp;quot; reads &amp;quot;I've successfully queried the blog_posts table for the 5 most recent titles. The SQL query, SELECT title FROM blog_posts ORDER BY datetime_utc DESC LIMIT 5, is working as expected. Now, I will transition to creating the application using app_create as the next step.&amp;quot; An expanded &amp;quot;▼ Tool: app_create&amp;quot; box shows escaped JSON HTML: { &amp;quot;html&amp;quot;: &amp;quot;....&amp;quot; Below: &amp;quot;Recent Blog Headlines created.&amp;quot; with &amp;quot;View app&amp;quot; and &amp;quot;Edit&amp;quot; buttons, a collapsed &amp;quot;► Result: app_create&amp;quot; toggle, and a final message: &amp;quot;The app &amp;quot;Recent Blog Headlines&amp;quot; has been created. It displays the 5 most recent headlines from the blog_posts table in the content database.&amp;quot;" style="max-width: 100%;" /&gt;&lt;/p&gt;

&lt;h4 id="built-with-so-much-ai-assistance"&gt;Built with so much AI assistance&lt;/h4&gt;
&lt;p&gt;Datasette Apps started life back in April as &lt;a href="https://github.com/datasette/datasette-agent-edit/commits/b242a8fc2e200d01820dacb5bf9a060f659c3a18/"&gt;datasette-agent-artifacts&lt;/a&gt;, a plugin I have since renamed to &lt;code&gt;datasette-agent-edit&lt;/code&gt; keeping only &lt;a href="https://simonwillison.net/2026/Jun/7/datasette-agent-edit/"&gt;its editing tools&lt;/a&gt;. I built that as one of the first plugins for &lt;a href="https://datasette.io/blog/2026/datasette-agent/"&gt;Datasette Agent&lt;/a&gt;, to help get the plugin hooks into the right shape. That first prototype was mainly built using Claude Opus 4.6 in Claude Code.&lt;/p&gt;
&lt;p&gt;When I switched track to Datasette Apps I started &lt;a href="https://github.com/datasette/datasette-apps/commit/fc1e23b801b5845647dcd423d632339648acf19c#diff-de64950fcb0bc622027de0d657eeb322f3520ce502d826813ff7653b51cf6059"&gt;with a plan&lt;/a&gt; constructed using Codex Desktop and GPT-5.5 xhigh, based on extensive dialog and feeding in both &lt;code&gt;datasette-agent-artifacts&lt;/code&gt; and other prototypes I had built.&lt;/p&gt;
&lt;p&gt;Most of the work that followed stuck with Codex, but in the few short days that we had access &lt;a href="https://simonwillison.net/2026/Jun/9/claude-fable-5/"&gt;to Claude Fable 5&lt;/a&gt; I had it run a security evaluation of the product (an ability that would get it &lt;a href="https://simonwillison.net/2026/Jun/13/us-government-directive-to-suspend-access/"&gt;banned by the US government&lt;/a&gt; shortly afterwards) and it found a very real problem.&lt;/p&gt;
&lt;p&gt;I was allowing users to allow-list CSP hosts for their apps, but Fable pointed out the following attack:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A less privileged user with &lt;code&gt;create-app&lt;/code&gt; permission creates an app that queries SQLite for all available tables and selects and exfiltrates all of the data to a host they had allow-listed via CSP.&lt;/li&gt;
&lt;li&gt;They then trick an administrator user with access to private data into visiting their app.&lt;/li&gt;
&lt;li&gt;... and the app can now run queries as &lt;em&gt;that&lt;/em&gt; user and steal their private data!&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That's clearly unacceptable. I fixed it by restricting the ability to allow-list any domain to a new &lt;code&gt;apps-set-csp&lt;/code&gt; permission, which is intended just for trusted staff. Site administrators can also &lt;a href="https://github.com/datasette/datasette-apps#sandboxed-apps"&gt;configure Datasette&lt;/a&gt; with a list of &lt;code&gt;allowed_csp_origins&lt;/code&gt;, which regular users can then select. This means you can do things like allow &lt;code&gt;cdnjs.cloudflare.com&lt;/code&gt; and your users will be able to build apps that load extra JavaScript libraries from the &lt;a href="https://cdnjs.com"&gt;cdnjs&lt;/a&gt; CDN.&lt;/p&gt;
&lt;p&gt;I've reviewed Datasette Apps extremely closely, especially the security-adjacent parts of it. The critical sandbox and CSP configuration are based on multiple AI-assisted prototypes and tests.&lt;/p&gt;
&lt;h4 id="it-s-looking-good-so-far"&gt;It's looking good so far&lt;/h4&gt;
&lt;p&gt;I'm really pleased with this initial release.&lt;/p&gt;
&lt;p&gt;Datasette is growing beyond its origins as an application for serving read-only data into a much richer ecosystem of tools for doing useful things with that data once it has been collected.&lt;/p&gt;
&lt;p&gt;Datasette's roots are in data journalism. I've always been interested in the question of what comes &lt;em&gt;next&lt;/em&gt; after a journalist gets their hands on a giant dump of data about the world. Datasette supports exploring and publishing it. Datasette Agent adds interrogating it with AI assistance. Now Datasette Apps expands that to building custom interfaces and visualizations to help unlock the stories that are hidden within.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/iframes"&gt;iframes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/content-security-policy"&gt;content-security-policy&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-apps"&gt;datasette-apps&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="iframes"/><category term="javascript"/><category term="projects"/><category term="sandboxing"/><category term="ai"/><category term="datasette"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="content-security-policy"/><category term="datasette-apps"/></entry><entry><title>Quoting Charity Majors</title><link href="https://simonwillison.net/2026/Jun/17/charity-majors/" rel="alternate"/><published>2026-06-17T17:12:41+00:00</published><updated>2026-06-17T17:12:41+00:00</updated><id>https://simonwillison.net/2026/Jun/17/charity-majors/</id><summary type="html">
    &lt;blockquote cite="https://charitydotwtf.substack.com/p/ai-demands-more-engineering-discipline#footnote-2"&gt;&lt;p&gt;What happened in 2025 was this: &lt;strong&gt;the economics of code production were turned upside down&lt;/strong&gt;. Instead of being very hard, time-consuming, and expensive to generate code, it became effectively free and instant. Lines of code went from being treasured, reused, cared for and carefully curated, to being disposable and regenerable, practically overnight.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://charitydotwtf.substack.com/p/ai-demands-more-engineering-discipline#footnote-2"&gt;Charity Majors&lt;/a&gt;, AI demands more engineering discipline. Not less&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/charity-majors"&gt;charity-majors&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="charity-majors"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="ai"/><category term="llms"/></entry><entry><title>Quoting Georgi Gerganov</title><link href="https://simonwillison.net/2026/Jun/16/georgi-gerganov/" rel="alternate"/><published>2026-06-16T16:04:59+00:00</published><updated>2026-06-16T16:04:59+00:00</updated><id>https://simonwillison.net/2026/Jun/16/georgi-gerganov/</id><summary type="html">
    &lt;blockquote cite="https://news.ycombinator.com/item?id=48555993#48557304"&gt;&lt;p&gt;I can 100% attest to the fact that Qwen3.6-27B is a very capable local model for coding tasks. Over the last month and a half I've been using it almost daily, either on my M2 Ultra or on my RTX 5090 box. I use it for small &lt;a href="https://github.com/search?q=%22Assisted-by%22+user%3Aggml-org&amp;amp;type=commits&amp;amp;ref=advsearch"&gt;mundane tasks at ggml-org&lt;/a&gt; - nothing really impressive, but definitely a helpful tool for a maintainer. I think I would be using it much more, if I didn't have to spend a lot of my time on reviewing PRs. Currently, I have a very lightweight harness - the pi agent with everything stripped (&lt;code&gt;pi -nc --offline&lt;/code&gt;) and &lt;a href="https://github.com/ggml-org/llama.cpp/blob/master/.pi/gg/SYSTEM.md"&gt;a short system prompt&lt;/a&gt; to align it a bit with my style.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://news.ycombinator.com/item?id=48555993#48557304"&gt;Georgi Gerganov&lt;/a&gt;, Hacker News comment on &lt;a href="https://vickiboykis.com/2026/06/15/running-local-models-is-good-now/"&gt;Running local models is good now&lt;/a&gt; by Vicki Boykis&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/georgi-gerganov"&gt;georgi-gerganov&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pi"&gt;pi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/local-llms"&gt;local-llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/qwen"&gt;qwen&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;&lt;/p&gt;



</summary><category term="georgi-gerganov"/><category term="llms"/><category term="ai"/><category term="generative-ai"/><category term="pi"/><category term="ai-assisted-programming"/><category term="local-llms"/><category term="qwen"/><category term="coding-agents"/></entry><entry><title>Claude Fable is relentlessly proactive</title><link href="https://simonwillison.net/2026/Jun/11/fable-is-relentlessly-proactive/" rel="alternate"/><published>2026-06-11T23:35:17+00:00</published><updated>2026-06-11T23:35:17+00:00</updated><id>https://simonwillison.net/2026/Jun/11/fable-is-relentlessly-proactive/</id><summary type="html">
    &lt;p&gt;After two days of experience with &lt;a href="https://simonwillison.net/2026/Jun/9/claude-fable-5/"&gt;Claude Fable 5&lt;/a&gt; I think the best way to describe it is &lt;strong&gt;relentlessly proactive&lt;/strong&gt;. It knows a whole lot of tricks and it will deploy pretty much any of them to get to its goal.&lt;/p&gt;
&lt;p&gt;I'll illustrate this with an example. I was hacking on &lt;a href="https://agent.datasette.io/"&gt;Datasette Agent&lt;/a&gt; today when I noticed a glitch: a horizontal scrollbar that shouldn't be there in the jump menu chat prompt. I snapped this screenshot:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/jump-to-bug.jpg" alt="Screenshot of a modal dialog demonstrating a scrollbar bug. At the top is a focused search input with blue outline and placeholder &amp;quot;Jump to...&amp;quot;, with an X close button to its right. Below, a heading reads &amp;quot;Start a new agent chat&amp;quot; above a textarea with the placeholder &amp;quot;Ask a question about your data...&amp;quot; — the bug: a thick gray horizontal scrollbar is incorrectly displayed along the bottom edge of the empty textarea, spanning nearly its full width, next to the resize handle. Below the textarea: &amp;quot;Press Enter to start. Shift+Enter adds a new line.&amp;quot; followed by a blue &amp;quot;Start chat&amp;quot; button." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Then I started a fresh &lt;code&gt;claude&lt;/code&gt; session in my &lt;code&gt;datasette-agent&lt;/code&gt; checkout, dragged in the screenshot and told it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Look at dependencies to help figure out why there is a horizontal scrollbar here&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I had a hunch the cause was in a dependency of Datasette Agent (likely Datasette itself) and I knew Fable was good at digging into dependency code, either by inspecting installed files in its own virtual environment &lt;code&gt;site-packages&lt;/code&gt; or by referencing a local checkout on disk. Telling it to start with dependencies felt like a good bet.&lt;/p&gt;
&lt;p&gt;I got distracted by a domestic task and wandered away from my computer.&lt;/p&gt;
&lt;p&gt;When I came back a few minutes later I saw my machine &lt;em&gt;open a browser window&lt;/em&gt; in my regular Firefox and then &lt;em&gt;navigate to the dialog in question&lt;/em&gt;. I had not told Claude Code to use any browser automation, and I was pretty sure it wasn't possible for it to trigger mouse movements or keyboard shortcuts within a window, so how was it doing that?&lt;/p&gt;
&lt;p&gt;I watched in fascination as it continued with its explorations, then saw it open a Safari window instead of Firefox. I also grabbed this snapshot from the Claude terminal:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/fable-bash-pyobjc.jpg" alt="Screenshot of two Bash tool calls in a dark terminal interface. First: Bash(open -a Safari /tmp/textarea-scrollbar-test.html &amp;amp;&amp;amp; sleep 4 &amp;amp;&amp;amp; uv run --with pyobjc-framework-Quartz python - &amp;lt;&amp;lt;'EOF' import Quartz wins = Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID) for w in wins: if (w.get('kCGWindowOwnerName') or '') == 'Safari' and 'textarea' in (w.get('kCGWindowName') or '').lower(): print(w.get('kCGWindowNumber')) EOF) with output 153551. Second: Bash(screencapture -x -o -l 153551 /tmp/safari-cases.png &amp;amp;&amp;amp; echo ok) with output ok." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;What was it doing there with &lt;code&gt;uv run --with pyobjc-framework-Quartz&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;It turns out Fable had hacked up its own pattern for taking screenshots of browser windows. It was using Python to iterate through all available windows on my machine, then filtering for Safari windows with expected strings such as &lt;code&gt;"textarea"&lt;/code&gt; in the window name. It used that to find their window number - an integer like 153551 - which it could then use with the &lt;code&gt;screencapture&lt;/code&gt; CLI tool to grab a PNG.&lt;/p&gt;
&lt;p&gt;OK fine, that's a neat way of taking screenshots. But what was it taking screenshots of?&lt;/p&gt;
&lt;p&gt;Turns out it had been writing its own scratch HTML pages to try and recreate the bug, then opening Safari and grabbing screenshots.&lt;/p&gt;
&lt;p&gt;Here's that &lt;a href="https://static.simonwillison.net/static/2026/textarea-scrollbar-test.html"&gt;/tmp/textarea-scrollbar-test.html&lt;/a&gt; page it created, and the screenshot it took with &lt;code&gt;screencapture -x -o -l 153551 /tmp/safari-cases.png&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/safari-cases.jpg" alt="Screenshot of a Safari browser window showing a textarea scrollbar test page at file:///private/tmp/textarea-scrollbar-test.html. Page text reads: scrollbar thickness: 17px | UA: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15 | devicePixelRatio: 2. Four numbered test cases follow, each with a textarea containing the placeholder &amp;quot;Ask a question about your data...&amp;quot;: 1. Exact plugin CSS (resize: vertical, default overflow), 2. Plugin CSS + overflow-x: hidden, 3. Plugin CSS + resize: none, and 4. Bare default textarea, which is a much smaller box with the placeholder wrapping onto two lines." style="max-width: 100%;" /&gt;
(I have way too many open tabs!)&lt;/p&gt;
&lt;p&gt;OK, so I can see how it's opening test pages and taking screenshots, but how on earth was it triggering the modal dialog that was meant to be under test? That's only available via a click or a keyboard shortcut, and I couldn't see a mechanism for it to run those in Safari.&lt;/p&gt;
&lt;p&gt;I eventually figured out what it had done.&lt;/p&gt;
&lt;p&gt;Claude was running in a folder that contained the source code for the application. It knows enough about &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; to be able to run a local development server. It turns out it was editing Datasette's own templates to add JavaScript that would trigger the correct keyboard shortcut as soon as the window opened, adding code like this:&lt;/p&gt;
&lt;div class="highlight highlight-text-html-basic"&gt;&lt;pre&gt;&lt;span class="pl-kos"&gt;&amp;lt;&lt;/span&gt;&lt;span class="pl-ent"&gt;script&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addEventListener&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"load"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-en"&gt;setTimeout&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;dispatchEvent&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;KeyboardEvent&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"keydown"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;key&lt;/span&gt;: &lt;span class="pl-s"&gt;"/"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;bubbles&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;1200&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="pl-ent"&gt;script&lt;/span&gt;&lt;span class="pl-kos"&gt;&amp;gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;1.2 seconds after the window opens, this code triggers a simulated &lt;code&gt;/&lt;/code&gt; key, which is the keyboard shortcut for opening the modal dialog.&lt;/p&gt;
&lt;p&gt;There was one challenge left. In order to understand what was going on, Claude needed to run JavaScript on the page to take measurements for itself.&lt;/p&gt;
&lt;p&gt;It wrote its own custom web application to capture information via CORS, then ran that as a local server and opened a page with JavaScript that would POST directly to it!&lt;/p&gt;
&lt;p&gt;Here's the Python web app it wrote, using the standard library &lt;a href="https://docs.python.org/3/library/http.server.html"&gt;http.server&lt;/a&gt; package:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;http&lt;/span&gt;.&lt;span class="pl-s1"&gt;server&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;HTTPServer&lt;/span&gt;, &lt;span class="pl-v"&gt;BaseHTTPRequestHandler&lt;/span&gt;

&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-c1"&gt;H&lt;/span&gt;(&lt;span class="pl-v"&gt;BaseHTTPRequestHandler&lt;/span&gt;):
    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;do_POST&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;):
        &lt;span class="pl-s1"&gt;n&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;int&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;headers&lt;/span&gt;.&lt;span class="pl-c1"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"Content-Length"&lt;/span&gt;, &lt;span class="pl-c1"&gt;0&lt;/span&gt;))
        &lt;span class="pl-en"&gt;open&lt;/span&gt;(&lt;span class="pl-s"&gt;"/tmp/diag.json"&lt;/span&gt;, &lt;span class="pl-s"&gt;"w"&lt;/span&gt;).&lt;span class="pl-c1"&gt;write&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;rfile&lt;/span&gt;.&lt;span class="pl-c1"&gt;read&lt;/span&gt;(&lt;span class="pl-s1"&gt;n&lt;/span&gt;).&lt;span class="pl-c1"&gt;decode&lt;/span&gt;())
        &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;send_response&lt;/span&gt;(&lt;span class="pl-c1"&gt;200&lt;/span&gt;)
        &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;send_header&lt;/span&gt;(&lt;span class="pl-s"&gt;"Access-Control-Allow-Origin"&lt;/span&gt;, &lt;span class="pl-s"&gt;"*"&lt;/span&gt;)
        &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;end_headers&lt;/span&gt;()
    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;do_OPTIONS&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;):
        &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;send_response&lt;/span&gt;(&lt;span class="pl-c1"&gt;200&lt;/span&gt;)
        &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;send_header&lt;/span&gt;(&lt;span class="pl-s"&gt;"Access-Control-Allow-Origin"&lt;/span&gt;, &lt;span class="pl-s"&gt;"*"&lt;/span&gt;)
        &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;send_header&lt;/span&gt;(&lt;span class="pl-s"&gt;"Access-Control-Allow-Headers"&lt;/span&gt;, &lt;span class="pl-s"&gt;"*"&lt;/span&gt;)
        &lt;span class="pl-s1"&gt;self&lt;/span&gt;.&lt;span class="pl-c1"&gt;end_headers&lt;/span&gt;()
    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;log_message&lt;/span&gt;(&lt;span class="pl-s1"&gt;self&lt;/span&gt;, &lt;span class="pl-c1"&gt;*&lt;/span&gt;&lt;span class="pl-s1"&gt;a&lt;/span&gt;):  &lt;span class="pl-c"&gt;# quiet&lt;/span&gt;
        &lt;span class="pl-k"&gt;pass&lt;/span&gt;

&lt;span class="pl-en"&gt;HTTPServer&lt;/span&gt;((&lt;span class="pl-s"&gt;"127.0.0.1"&lt;/span&gt;, &lt;span class="pl-c1"&gt;9999&lt;/span&gt;), &lt;span class="pl-c1"&gt;H&lt;/span&gt;).&lt;span class="pl-c1"&gt;serve_forever&lt;/span&gt;()&lt;/pre&gt;
&lt;p&gt;All this does is accept a POST request full of JSON and write that to the &lt;code&gt;/tmp/diag.json&lt;/code&gt; file. It sends &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; headers (including from &lt;code&gt;OPTIONS&lt;/code&gt; requests) so that code running on another domain can still communicate back to it.&lt;/p&gt;
&lt;p&gt;Then Claude injected this code into the template that it was loading in a browser:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;host&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;querySelector&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"navigation-search"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;ta&lt;/span&gt;   &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;host&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;shadowRoot&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;querySelector&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"textarea"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;cs&lt;/span&gt;   &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;getComputedStyle&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;"http://127.0.0.1:9999/diag"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-c1"&gt;method&lt;/span&gt;: &lt;span class="pl-s"&gt;"POST"&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-c1"&gt;body&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c1"&gt;dpr&lt;/span&gt;: &lt;span class="pl-smi"&gt;window&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;devicePixelRatio&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;scrollWidth&lt;/span&gt;: &lt;span class="pl-s1"&gt;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;scrollWidth&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;clientWidth&lt;/span&gt;: &lt;span class="pl-s1"&gt;ta&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;clientWidth&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
    &lt;span class="pl-c1"&gt;whiteSpace&lt;/span&gt;: &lt;span class="pl-s1"&gt;cs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;whiteSpace&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-c1"&gt;width&lt;/span&gt;: &lt;span class="pl-s1"&gt;cs&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;width&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This took measurements of the &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt; inside the &lt;code&gt;&amp;lt;navigation-search&amp;gt;&lt;/code&gt; Web Component and sent them to the server, which wrote them to a file on disk, which Claude could then read.&lt;/p&gt;
&lt;p&gt;Having figured out all of these tricks Fable... hit some invisible guardrail and downgraded itself to Opus. Thankfully Opus had access to the full transcript and could continue using the tricks pioneered by Fable, and shortly afterwards found, tested and verified &lt;a href="https://github.com/datasette/datasette-agent/commit/a75a8b727b42c30ced1fc41dc8add7eb9f04fefe"&gt;the fix&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I prompted Opus to:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Write a report in /tmp/automation-report.md where you note down all of the tricks you have used in this session to test against real browsers on my computer, include runnable code examples&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Which produced &lt;a href="https://gist.github.com/simonw/aef7f7db9ac992643110a74e43d6d42f"&gt;this report&lt;/a&gt;, which was invaluable for piecing together the details of what had happened for this post.&lt;/p&gt;
&lt;p&gt;I've shared &lt;a href="https://gisthost.github.io/?cc14774f6d37eb67bf089f3ac3925f8f"&gt;the full terminal transcript&lt;/a&gt; of the Claude Code session as well.&lt;/p&gt;
&lt;h4 id="a-review-of-everything-it-did"&gt;A review of everything it did&lt;/h4&gt;
&lt;p&gt;Based on a screenshot and a one-line prompt, Claude Fable 5 + Claude Code:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Figured out the recipe to run the local development server (with fake environment variables needed to get it running)&lt;/li&gt;
&lt;li&gt;Fired up a Playwright Chrome session&lt;/li&gt;
&lt;li&gt;Turned on the visible scrollbars setting for Chrome &lt;code&gt;defaults write com.google.chrome.for.testing AppleShowScrollBars Always&lt;/code&gt; (it turned that off again later)&lt;/li&gt;
&lt;li&gt;Cycled through Firefox and WebKit in Playwright too, failing to recreate the bug&lt;/li&gt;
&lt;li&gt;Worked out my default browser was Safari&lt;/li&gt;
&lt;li&gt;Built a &lt;code&gt;textarea-scrollbar-test.html&lt;/code&gt; HTML document&lt;/li&gt;
&lt;li&gt;Opened that in real (not Playwright) Firefox&lt;/li&gt;
&lt;li&gt;Found that &lt;code&gt;osascript -e 'tell application "System Events" to tell process "firefox" to id of window 1'&lt;/code&gt; was blocked because "osascript is not allowed assistive access"&lt;/li&gt;
&lt;li&gt;Figured out that &lt;code&gt;uv run --with pyobjc-framework-Quartz python&lt;/code&gt; workaround, described above&lt;/li&gt;
&lt;li&gt;Added JavaScript to the site templates in order to trigger the &lt;code&gt;/&lt;/code&gt; key&lt;/li&gt;
&lt;li&gt;Built its own little Python CORS web server to capture JSON data&lt;/li&gt;
&lt;li&gt;Rewrote the template to capture that data and send it to the server&lt;/li&gt;
&lt;li&gt;Scripted its way through the Web Component shadow DOM to the information it needed&lt;/li&gt;
&lt;li&gt;Opened Safari to confirm the source of the bug&lt;/li&gt;
&lt;li&gt;Modified its custom template to hack in a potential fix&lt;/li&gt;
&lt;li&gt;Confirmed the hacked fix worked&lt;/li&gt;
&lt;li&gt;Reported back on how to fix the problem&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Like I said, relentlessly proactive!&lt;/p&gt;
&lt;h4 id="an-estimate-of-the-cost"&gt;An estimate of the cost&lt;/h4&gt;
&lt;p&gt;I'm currently on the $100/month Claude Max plan, which includes a generous allowance for Fable up until June 22nd after which Anthropic say they'll start charging full API prices for it.&lt;/p&gt;
&lt;p&gt;I'm using &lt;a href="https://www.agentsview.io"&gt;AgentsView&lt;/a&gt; to track my spending (see &lt;a href="https://til.simonwillison.net/llms/agentsview-custom-model-price"&gt;this TIL&lt;/a&gt;). Here's what AgentsView says this session would have cost me if I was paying full price for it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % uvx agentsview session usage be8850a7-6119-46a0-b5d6-79c7fff5ae2b
Session:       be8850a7-6119-46a0-b5d6-79c7fff5ae2b
Agent:         claude
Output:        68606
Peak ctx:      113178
Cost:          ~$12.11 (claude-fable-5, claude-opus-4-8)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you don't keep a close eye on it, Fable will quite happily burn $12 in tokens inventing new ways to debug your CSS.&lt;/p&gt;
&lt;h4 id="i-really-need-to-lock-this-thing-down"&gt;I really need to lock this thing down&lt;/h4&gt;
&lt;p&gt;On the one hand, watching Fable go to extreme lengths to get the information that it needed to debug what was, in the end, a two-line CSS fix, was &lt;em&gt;fascinating&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;But on the other hand... this is a robust reminder that coding agents can do anything &lt;em&gt;you&lt;/em&gt; can do by typing commands into a terminal - and frontier models know every trick in the book, and evidently a few that nobody has ever written down before.&lt;/p&gt;
&lt;p&gt;If Fable had been acting on malicious instructions - a prompt injection attack hidden in code or an issue thread, or something I'd carelessly pasted into my terminal - it's alarming to think quite how far it could go to exfiltrate data or cause other forms of mischief.&lt;/p&gt;
&lt;p&gt;Running coding agents outside of a sandbox has always been a bad idea - it's my top contender for &lt;a href="https://simonwillison.net/2026/Jan/8/llm-predictions-for-2026/#1-year-a-challenger-disaster-for-coding-agent-security"&gt;a Challenger disaster&lt;/a&gt; incident, as described by Johann Rehberger in &lt;a href="https://embracethered.com/blog/posts/2025/the-normalization-of-deviance-in-ai/"&gt;The Normalization of Deviance in AI&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Fable is arguably smarter and hence more suspicious of potentially malicious instructions. But that smartness is very much a two-edged sword: if it &lt;em&gt;does&lt;/em&gt; get subverted by instructions, the amount of damage it can do given its relentless proactivity is terrifying.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-injection"&gt;prompt-injection&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-mythos-fable"&gt;claude-mythos-fable&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="ai"/><category term="prompt-injection"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="coding-agents"/><category term="claude-code"/><category term="claude-mythos-fable"/></entry><entry><title>datasette 1.0a33</title><link href="https://simonwillison.net/2026/Jun/11/datasette/" rel="alternate"/><published>2026-06-11T15:26:49+00:00</published><updated>2026-06-11T15:26:49+00:00</updated><id>https://simonwillison.net/2026/Jun/11/datasette/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette/releases/tag/1.0a33"&gt;datasette 1.0a33&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;This alpha is a significant step on the road to a stable 1.0, finally extending the &lt;code&gt;?_extra=&lt;/code&gt; pattern I introduced &lt;a href="https://docs.datasette.io/en/1.0a3/changelog.html#a3-2023-08-09"&gt;in Datasette 1.0a3&lt;/a&gt; to cover queries and rows in addition to tables. That pattern is also &lt;a href="https://docs.datasette.io/en/latest/json_api.html#expanding-json-responses"&gt;now documented&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;I wrote a whole lot more about the new release on the Datasette project blog: &lt;strong&gt;&lt;a href="http://datasette.io/blog/2026/api-extras/"&gt;Datasette 1.0a33 with JSON extras in the API&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Because API explorer tools are almost free to build now I had Claude Fable 5 in Claude Code (for &lt;a href="https://gist.github.com/simonw/d8bf1a8f36e28fbd595cede946e0ab6d"&gt;the plan&lt;/a&gt;) and GPT-5.5 xhigh in Codex Desktop (for &lt;a href="https://gist.github.com/simonw/12d5e09797072a6807d7b9cfcc8ff6b7"&gt;the implementation&lt;/a&gt;) build me this &lt;a href="https://tools.simonwillison.net/datasette-extras-explorer"&gt;custom extras API explorer&lt;/a&gt; to help demonstrate the feature:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a web application titled &amp;quot;Datasette extras explorer&amp;quot;. A URL input field contains https://latest.datasette.io/fixtures/facetable.json with a teal Explore button next to it. Below, a left panel labeled EXTRAS (30) lists checkboxes: all_columns - All columns in the table, regardless of _col/_nocol filtering; column_types - Column type assignments for this table; columns (checked) - Column names returned by this query; count - Total count of rows matching these filters; count_sql - SQL query used to calculate the total count; custom_table_templates - Custom template names considered for this table; database - Database name; database_color - Color assigned to the database. A right panel labeled RESPONSE shows GET /fixtures/fac… with Copy JSON and Copy URL buttons, then a dark JSON viewer showing 200 - 9.9 KB - 114ms and JSON: &amp;quot;ok&amp;quot;: true, &amp;quot;next&amp;quot;: null, &amp;quot;columns&amp;quot;: (highlighted array) &amp;quot;pk&amp;quot;, &amp;quot;created&amp;quot;, &amp;quot;planet_int&amp;quot;, &amp;quot;on_earth&amp;quot;, &amp;quot;state&amp;quot;, &amp;quot;_city_id&amp;quot;, &amp;quot;_neighborhood&amp;quot;, &amp;quot;tags&amp;quot;, &amp;quot;complex_array&amp;quot;, &amp;quot;distinct_some_null&amp;quot;, &amp;quot;n&amp;quot;, &amp;quot;rows&amp;quot;: list of objects." src="https://static.simonwillison.net/static/2026/extras-explorer.png" /&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="datasette"/><category term="annotated-release-notes"/><category term="ai-assisted-programming"/></entry><entry><title>Running Python code in a sandbox with MicroPython and WASM</title><link href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/" rel="alternate"/><published>2026-06-06T03:53:34+00:00</published><updated>2026-06-06T03:53:34+00:00</updated><id>https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/</id><summary type="html">
    &lt;p&gt;I've been experimenting with different approaches to running code in a sandbox for several years now, but my latest attempt feels like it might finally have all of the characteristics I've been looking for. I've released it as an alpha package called &lt;a href="https://github.com/simonw/micropython-wasm"&gt;micropython-wasm&lt;/a&gt;, and I'm using it for a code execution sandbox plugin for &lt;a href="https://github.com/datasette/datasette-agent"&gt;Datasette Agent&lt;/a&gt; called &lt;a href="https://github.com/datasette/datasette-agent-micropython"&gt;datasette-agent-micropython&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#why-do-i-want-a-sandbox-"&gt;Why do I want a sandbox?&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#what-i-want-from-a-sandbox"&gt;What I want from a sandbox&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#webassembly-looks-really-promising-here"&gt;WebAssembly looks really promising here&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#micropython-in-webassembly"&gt;MicroPython in WebAssembly&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#building-the-first-version"&gt;Building the first version&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#try-it-yourself"&gt;Try it yourself&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#should-you-trust-my-vibe-coded-sandbox-"&gt;Should you trust my vibe-coded sandbox?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="why-do-i-want-a-sandbox-"&gt;Why do I want a sandbox?&lt;/h4&gt;
&lt;p&gt;My key open source projects - &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;, &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt;, even &lt;a href="https://sqlite-utils.datasette.io/"&gt;sqlite-utils&lt;/a&gt; - all support plugins.&lt;/p&gt;
&lt;p&gt;I absolutely love plugins as a mechanism for extending software. A carefully designed plugin system reduces the risk involved in trying new things to almost nothing - even the wildest ideas won't leave a lasting influence on the core application itself. My software can grow a new feature overnight and I don't even have to review a pull request!&lt;/p&gt;
&lt;p&gt;There's one major drawback: my plugin systems all use Python and &lt;a href="https://pluggy.readthedocs.io/en/latest/"&gt;Pluggy&lt;/a&gt;, and plugin code executes with full privileges within my applications. A buggy or malicious plugin could break everything or leak private data.&lt;/p&gt;
&lt;p&gt;I'd love to be able to run plugin-style code in an environment where it is unable to read unapproved files, connect to a network, or generally operate in a way that's risky or harmful to the rest of the application or the user's computer.&lt;/p&gt;
&lt;p&gt;My interest covers more than just plugins. For Datasette in particular there are many features I'd like to support where arbitrary code execution would be useful. I've already experimented with this for &lt;a href="https://enrichments.datasette.io/"&gt;Datasette Enrichments&lt;/a&gt;, where code can be used to transform values stored in a table. I'd love to build a mechanism where you can run code on a schedule that fetches JSON from an approved location, runs a tiny bit of code to reformat it into a list of dictionaries, then inserts those as rows in a SQLite database table.&lt;/p&gt;
&lt;h4 id="what-i-want-from-a-sandbox"&gt;What I want from a sandbox&lt;/h4&gt;
&lt;p&gt;My goal is to execute code safely within my own Python applications. Here's what I need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dependencies that &lt;strong&gt;cleanly install from PyPI&lt;/strong&gt;, including binary wheels across multiple platforms if necessary. I don't want people using my software to have to take any extra steps beyond directly installing my Python package.&lt;/li&gt;
&lt;li&gt;Executed code must be subject to both &lt;strong&gt;memory&lt;/strong&gt; and &lt;strong&gt;CPU&lt;/strong&gt; limits. I don't want &lt;code&gt;while True: s += "longer string"&lt;/code&gt; to crash my application or the user's computer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File access must be strictly controlled&lt;/strong&gt;. Either no filesystem access at all or I get to define exactly which files can be read and which files can be written to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network access is controlled as well&lt;/strong&gt;. Sandboxed code should not be able to communicate with anything without going through a layer I fully control.&lt;/li&gt;
&lt;li&gt;Support for interaction with &lt;strong&gt;host functions&lt;/strong&gt;. A sandbox isn't much use if I can't carefully expose selected platform features to the code that it's running.&lt;/li&gt;
&lt;li&gt;It has to be &lt;strong&gt;robust, supported, and clearly documented&lt;/strong&gt;. I've lost count of the number of sandbox projects I've seen in repos with warnings that they aren't actively maintained!&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="webassembly-looks-really-promising-here"&gt;WebAssembly looks really promising here&lt;/h4&gt;
&lt;p&gt;Web browsers operate in the most hostile environment imaginable when it comes to malicious code. Their job is to download &lt;em&gt;and execute&lt;/em&gt; untrusted code from the web on almost every page load.&lt;/p&gt;
&lt;p&gt;Given this, JavaScript engines should be excellent candidates for sandboxes. Sadly those engines are also extremely complicated, and are not designed for easy embedding in other projects. Most of the V8-in-Python projects I've seen are infrequently maintained and come with warnings not to use them with completely untrusted code.&lt;/p&gt;
&lt;p&gt;WebAssembly is a &lt;em&gt;much better&lt;/em&gt; candidate. It was designed from the start to support all of the characteristics I care about and has been tested in browsers for nearly a decade. The &lt;a href="https://pypi.org/project/wasmtime"&gt;wasmtime&lt;/a&gt; Python library brings WASM to Python, is actively maintained, and has binary wheels.&lt;/p&gt;
&lt;h4 id="micropython-in-webassembly"&gt;MicroPython in WebAssembly&lt;/h4&gt;
&lt;p&gt;WebAssembly engines like wasmtime run WebAssembly binaries. Some programming languages like Rust are easy to compile directly to WebAssembly. Dynamic languages like JavaScript and Python are harder - they support language primitives like &lt;code&gt;eval()&lt;/code&gt;, which means they need a full interpreter available at runtime.&lt;/p&gt;
&lt;p&gt;To run Python we need a full Python interpreter compiled to WebAssembly, wired up in a way that makes it easy to feed it code, hook up host functions and access the results.&lt;/p&gt;
&lt;p&gt;Pyodide offers an outstanding package for running Python using WebAssembly in the browser, but using Pyodide in server-side Python isn't supported. The most recent advice I could find was &lt;a href="https://github.com/pyodide/pyodide/discussions/5145"&gt;from October 2024&lt;/a&gt; stating "Pyodide is built by the Emscripten toolchain and can only run in a browser or Node.js".&lt;/p&gt;
&lt;p&gt;The other day I decided to take a look at &lt;a href="https://micropython.org"&gt;MicroPython&lt;/a&gt; as an option for this. The MicroPython site says:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;MicroPython is a lean and efficient implementation of the Python 3 programming language that includes a small subset of the Python standard library and is optimised to run on microcontrollers and in constrained environments.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;WebAssembly sure feels like a constrained environment to me!&lt;/p&gt;
&lt;h4 id="building-the-first-version"&gt;Building the first version&lt;/h4&gt;
&lt;p&gt;I had GPT-5.5 Pro &lt;a href="https://chatgpt.com/share/6a1e2a5c-58b8-8328-ba1c-0e6aadb0a051"&gt;do some research for me&lt;/a&gt;, which turned up &lt;a href="https://github.com/micropython/micropython/pull/13676"&gt;this PR against MicroPython&lt;/a&gt; by &lt;a href="https://github.com/yamt"&gt;Yamamoto Takahashi&lt;/a&gt; titled "Experimental WASI support for ports/unix".&lt;/p&gt;
&lt;p&gt;It then produced this &lt;a href="https://github.com/simonw/micropython-wasm/blob/c08fbd2276b15dc8c9bdff82845f750971f45647/research.md"&gt;research.md document&lt;/a&gt;, so I let Codex Desktop and GPT-5.5 high &lt;a href="https://gist.github.com/simonw/27461a16d76f28f8619c609444d544fe"&gt;loose on it&lt;/a&gt; to see what would happen:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;read the research.md document and build this. You will probably need to write a script that compiles a custom WASM version of MicroPython as part of this project - fetch the MicroPython code to a /tmp directory for this as part of that script.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It worked. I now had a prototype Python library that could execute Python code inside a WebAssembly sandbox!&lt;/p&gt;
&lt;p&gt;The trickiest piece to solve was persistent interpreter state. The WASM build we are using here exposes a single entry point which starts the interpreter, runs the code and then stops the interpreter at the end.&lt;/p&gt;
&lt;p&gt;This works fine for one-off scripts, but for Datasette Agent I want variables and functions to stay resident in memory so I can reuse them across multiple code execution calls.&lt;/p&gt;
&lt;p&gt;A neat thing about working with coding agents is that you can get from an idea to a proof of concept quickly. I prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;For keeping variables resident: what if we ran code inside micropython itself which called a host function get_next_python_code() and then passed that to eval() - and that host function blocked until new code was available, maybe by running in a thread with a queue? Could that or a similar idea help here?&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;After some iteration we got to a version of this that works! In Python code you can now do this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;micropython_wasm&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;MicroPythonSession&lt;/span&gt;

&lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-en"&gt;MicroPythonSession&lt;/span&gt;() &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;session&lt;/span&gt;:
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;session&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-s"&gt;"x = 10&lt;span class="pl-cce"&gt;\n&lt;/span&gt;print(x)"&lt;/span&gt;).&lt;span class="pl-c1"&gt;stdout&lt;/span&gt;)
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;session&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-s"&gt;"x += 5&lt;span class="pl-cce"&gt;\n&lt;/span&gt;print(x)"&lt;/span&gt;).&lt;span class="pl-c1"&gt;stdout&lt;/span&gt;)
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;session&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-s"&gt;"print(x * 2)"&lt;/span&gt;).&lt;span class="pl-c1"&gt;stdout&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Under the hood this starts a thread, sets up a request queue and then sends messages to that queue for the &lt;code&gt;session.run()&lt;/code&gt; command, each time waiting on a reply queue for the result of that execution. Inside WASM the MicroPython interpreter blocks waiting for a &lt;code&gt;__session_next__()&lt;/code&gt; host function to return the next line of code, which it runs &lt;code&gt;eval()&lt;/code&gt; on before calling &lt;code&gt;__session_result__({"id": request_id, "ok": True})&lt;/code&gt; when each block has been successfully executed.&lt;/p&gt;
&lt;p&gt;The other piece of complexity was supporting host functions, so my Python library could selectively expose functions that could then be called by code running in MicroPython.&lt;/p&gt;
&lt;p&gt;Codex ended up solving this with &lt;a href="https://github.com/simonw/micropython-wasm/blob/0.1a1/micropython_wasm/usercmodule/host/hostmodule.c"&gt;78 lines of C&lt;/a&gt;, which ends up compiled into the &lt;a href="https://github.com/simonw/micropython-wasm/blob/0.1a1/micropython_wasm/artifacts/micropython-wasi.wasm"&gt;362KB WebAssembly blob&lt;/a&gt; I'm distributing with the package.&lt;/p&gt;
&lt;p&gt;I am by no means a C programmer, but I've read the C and had two different models explain it to me (here's &lt;a href="https://claude.ai/share/62f74371-cc3c-44f2-b406-33d03513de9e"&gt;Claude's explanation&lt;/a&gt;) and I've subjected it to a barrage of tests.&lt;/p&gt;
&lt;p&gt;The great thing about working with WebAssembly is that if the C turns out to be fatally flawed the worst that can happen is the WebAssembly execution will fail with an exception. I can live with that risk.&lt;/p&gt;
&lt;p&gt;Memory limits are directly supported by wasmtime. CPU limits are a little harder: wasmtime offers a "fuel" concept to limit how many operations a WebAssembly call can execute, and that's the correct fit for this problem, but the units are hard to reason about. I'm experimenting with a 20 million default "fuel" setting now but I'm not confident that it's the most appropriate value.&lt;/p&gt;
&lt;h4 id="try-it-yourself"&gt;Try it yourself&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;micropython-wasm&lt;/code&gt; alpha is now &lt;a href="https://pypi.org/project/micropython-wasm"&gt;live on PyPI&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can try it from your own Python code as &lt;a href="https://github.com/simonw/micropython-wasm"&gt;described in the README&lt;/a&gt;. I've also added a simple CLI mode in &lt;a href="https://github.com/simonw/micropython-wasm/releases/tag/0.1a2"&gt;version 0.1a2&lt;/a&gt; which means you can try it using &lt;code&gt;uvx&lt;/code&gt; without first installing it like so:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx micropython-wasm -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;print("Hello world")&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; To see it run out of fuel:&lt;/span&gt;
uvx micropython-wasm -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;s = ""; while True: s += "longer"&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Outputs: micropython-wasm: guest exited with code 1&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You can also try it in &lt;a href="https://agent.datasette.io/"&gt;Datasette Agent&lt;/a&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx llm keys &lt;span class="pl-c1"&gt;set&lt;/span&gt; openai
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Paste in an OpenAI key, then:&lt;/span&gt;
uvx --with datasette-agent \
  --with datasette-agent-micropython \
  --prerelease allow \
  datasette --internal internal.db \
    -s plugins.datasette-llm.default_model gpt-5.5 \
    --root -o&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then navigate to &lt;a href="http://127.0.0.1:8001/-/agent"&gt;http://127.0.0.1:8001/-/agent&lt;/a&gt; and run the prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;show me some micropython&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/micropython-in-datasette-agent.jpg" style="max-width: 100%" alt="Screenshot of a chat application interface with a dark blue-grey header reading &amp;quot;home&amp;quot; on the left and &amp;quot;root&amp;quot; with a hamburger menu icon on the right. Below is a navigation row with &amp;quot;← Back&amp;quot; and &amp;quot;Chat&amp;quot; on the left and an &amp;quot;EXPORT&amp;quot; button on the right. A blue user message bubble reads &amp;quot;show me some micropython&amp;quot;. Below it a collapsed thinking section reads &amp;quot;▸Thinking: … to show the result clearly. After that, I can wrap up with a brief explanation!&amp;quot; followed by a &amp;quot;▶ Tool: execute_micropython&amp;quot; label. A code block follows: &amp;quot;# A tiny MicroPython example: blink-style logic + Fibonacci&amp;quot; / &amp;quot;def fib(n):&amp;quot; / &amp;quot;    a, b = 0, 1&amp;quot; / &amp;quot;    out = []&amp;quot; / &amp;quot;    for _ in range(n):&amp;quot; / &amp;quot;        out.append(a)&amp;quot; / &amp;quot;        a, b = b, a + b&amp;quot; / &amp;quot;    return out&amp;quot; / &amp;#39;print(&amp;quot;Hello from MicroPython!&amp;quot;)&amp;#39; / &amp;#39;print(&amp;quot;First 10 Fibonacci numbers:&amp;quot;, fib(10))&amp;#39; / &amp;quot;# MicroPython often runs on microcontrollers, e.g.:&amp;quot; / &amp;quot;# from machine import Pin&amp;quot; / &amp;quot;# led = Pin(2, Pin.OUT)&amp;quot; / &amp;quot;# led.value(1)  # turn LED on&amp;quot; / &amp;quot;# led.value(0)  # turn LED off&amp;quot;. Below a horizontal divider is the output: &amp;quot;Hello from MicroPython!&amp;quot; / &amp;quot;First 10 Fibonacci numbers: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]&amp;quot;, followed by a &amp;quot;▶ Result: execute_micropython&amp;quot; label. At the bottom is a text input field with placeholder &amp;quot;Type a message...&amp;quot; and a blue &amp;quot;Send&amp;quot; button." /&gt;&lt;/p&gt;

&lt;p&gt;You can try a live demo of that plugin running in Datasette Agent by signing into &lt;a href="https://agent.datasette.io"&gt;agent.datasette.io&lt;/a&gt; with your GitHub account.&lt;/p&gt;

&lt;h4 id="should-you-trust-my-vibe-coded-sandbox-"&gt;Should you trust my vibe-coded sandbox?&lt;/h4&gt;
&lt;p&gt;Having complained about immature, loosely-maintained sandboxing libraries, it's deeply ironic that I've now built my own!&lt;/p&gt;
&lt;p&gt;I deliberately slapped an alpha release version on it, and I'm not ready to recommend it to anyone who isn't willing to take a significant risk.&lt;/p&gt;
&lt;p&gt;I've put it through enough testing that I'm OK using it myself. I've shipped my first plugin that uses it, &lt;a href="https://github.com/datasette/datasette-agent-micropython"&gt;datasette-agent-micropython&lt;/a&gt;. I've also locked GPT-5.5 xhigh in that Datasette Agent plugin and &lt;a href="https://gist.github.com/simonw/5de497c44d25f9fd459c8aa2c959fe4a"&gt;challenged it to break out of the sandbox&lt;/a&gt; and so far it has not managed to.&lt;/p&gt;
&lt;p&gt;I'm hoping this implementation can convince some companies with professional security teams and high-stakes problems to commit to using Python in WebAssembly as a sandboxing approach and open source their own solutions.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex"&gt;codex&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-agent"&gt;datasette-agent&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/micropython"&gt;micropython&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="python"/><category term="sandboxing"/><category term="ai"/><category term="datasette"/><category term="webassembly"/><category term="pyodide"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="codex"/><category term="datasette-agent"/><category term="micropython"/></entry><entry><title>Pasted File Editor</title><link href="https://simonwillison.net/2026/Jun/2/pasted-file-editor/" rel="alternate"/><published>2026-06-02T04:13:36+00:00</published><updated>2026-06-02T04:13:36+00:00</updated><id>https://simonwillison.net/2026/Jun/2/pasted-file-editor/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/pasted-file-editor"&gt;Pasted File Editor&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;I really like how you can paste a large volume of text into &lt;a href="https://claude.ail"&gt;claude.ai&lt;/a&gt; (or the Claude desktop/mobile apps) and it will detect it as a large paste and turn it into a file attachment instead.&lt;/p&gt;
&lt;p&gt;I decided to have Codex desktop &lt;a href="https://gist.github.com/simonw/74c79119b487a5acce18b4dcc26b9f79"&gt;build me a version of that&lt;/a&gt; as a prototype.&lt;/p&gt;
&lt;p&gt;You can also open files directly - including images which will be shown as thumbnails - or drag files onto the textarea.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex"&gt;codex&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="javascript"/><category term="tools"/><category term="ai-assisted-programming"/><category term="claude"/><category term="codex"/></entry><entry><title>Not so locked in any more</title><link href="https://simonwillison.net/2026/May/14/not-so-locked-in/" rel="alternate"/><published>2026-05-14T22:53:49+00:00</published><updated>2026-05-14T22:53:49+00:00</updated><id>https://simonwillison.net/2026/May/14/not-so-locked-in/</id><summary type="html">
    &lt;p&gt;This &lt;a href="https://simonwillison.net/2026/May/14/mitchell-hashimoto/"&gt;Mitchell Hashimoto quote&lt;/a&gt; about Bun migrating from Zig to Rust reminded me of a similar conversation I had at a conference last week.&lt;/p&gt;
&lt;p&gt;I was talking to someone who worked for a medium sized technology company with a pair of legacy/&lt;a href="https://simonwillison.net/2018/Jul/17/mark-norman-francis/"&gt;legendary&lt;/a&gt; iPhone and Android apps.&lt;/p&gt;
&lt;p&gt;They told me they had just completed a coding-agent driven rewrite of both apps to React Native.&lt;/p&gt;
&lt;p&gt;I asked why they chose that, given that coding agents presumably drive down the cost of maintaining separate iPhone and Android apps.&lt;/p&gt;
&lt;p&gt;They said that React Native has improved a lot over the past few years and covered everything their apps needed to do.&lt;/p&gt;
&lt;p&gt;And... if it turned out to be the wrong decision, they could &lt;strong&gt;just port back to native&lt;/strong&gt; in the future.&lt;/p&gt;
&lt;p&gt;Like Mitchell said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Programming languages used to be LOCK IN, and they're increasingly not so.&lt;/p&gt;
&lt;/blockquote&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/react"&gt;react&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai"/><category term="react"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="coding-agents"/></entry><entry><title>Welcome to the Datasette blog</title><link href="https://simonwillison.net/2026/May/13/welcome-to-the-datasette-blog/" rel="alternate"/><published>2026-05-13T23:59:39+00:00</published><updated>2026-05-13T23:59:39+00:00</updated><id>https://simonwillison.net/2026/May/13/welcome-to-the-datasette-blog/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://datasette.io/blog/2026/new-blog/"&gt;Welcome to the Datasette blog&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
We have a bunch of neat Datasette announcements in the pipeline so we decided it was time the project grew an official blog.&lt;/p&gt;
&lt;p&gt;I built this using OpenAI Codex desktop, which turns out to have the Markdown session transcript export feature I've always wanted. Here's &lt;a href="https://gist.github.com/simonw/885b11eee46822622b8031a1f4e5f3a3"&gt;the session that built the blog&lt;/a&gt;. See also &lt;a href="https://github.com/simonw/datasette.io/issues/179"&gt;issue 179&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex"&gt;codex&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai"/><category term="datasette"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="codex"/></entry><entry><title>Quoting James Shore</title><link href="https://simonwillison.net/2026/May/11/james-shore/" rel="alternate"/><published>2026-05-11T19:48:32+00:00</published><updated>2026-05-11T19:48:32+00:00</updated><id>https://simonwillison.net/2026/May/11/james-shore/</id><summary type="html">
    &lt;blockquote cite="https://www.jamesshore.com/v2/blog/2026/you-need-ai-that-reduces-your-maintenance-costs"&gt;&lt;p&gt;Your AI coding agent, the one you use to write code, needs to reduce your maintenance costs. Not by a little bit, either. You write code twice as quick now? Better hope you’ve halved your maintenance costs. Three times as productive? One third the maintenance costs. Otherwise, you’re screwed. You’re trading a temporary speed boost for permanent indenture. [...]&lt;/p&gt;
&lt;p&gt;The math only works if the LLM &lt;em&gt;decreases&lt;/em&gt; your maintenance costs, and by exactly the inverse of the rate it adds code. If you double your output and your cost of maintaining that output, two times two means you’ve quadrupled your maintenance costs. If you double your output and hold your maintenance costs steady, two times one means you’ve &lt;em&gt;still&lt;/em&gt; doubled your maintenance costs.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://www.jamesshore.com/v2/blog/2026/you-need-ai-that-reduces-your-maintenance-costs"&gt;James Shore&lt;/a&gt;, You Need AI That Reduces Maintenance Costs&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="agentic-engineering"/><category term="ai"/><category term="llms"/></entry><entry><title>Sightings</title><link href="https://simonwillison.net/2026/May/2/sightings/" rel="alternate"/><published>2026-05-02T17:26:40+00:00</published><updated>2026-05-02T17:26:40+00:00</updated><id>https://simonwillison.net/2026/May/2/sightings/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonwillison.net/elsewhere/sighting/"&gt;/elsewhere/sightings/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I have a new camera (a Canon R6 Mark II) so I'm taking a lot more photos of birds. I share my best wildlife photos on &lt;a href="https://www.inaturalist.org/"&gt;iNaturalist&lt;/a&gt;, and based on yesterday's &lt;a href="https://simonwillison.net/2026/May/1/inat-sightings/"&gt;successful prototype&lt;/a&gt;  I decided to add those to my blog.&lt;/p&gt;
&lt;p&gt;&lt;img class="blogmark-image" src="https://static.simonwillison.net/static/2026/beats-sightings.jpeg" alt="Screenshot of a &amp;quot;Sightings&amp;quot; webpage with a search bar and RSS icon, showing &amp;quot;Filters: Sorted by date&amp;quot; and &amp;quot;208 results page 1 / 7 next » last »»&amp;quot;. First entry: SIGHTING 7:51 PM — Acorn Woodpecker, with two photos labeled &amp;quot;Acorn Woodpecker&amp;quot; of black and white woodpeckers with red caps on tree branches, dated 2nd May 2026. Second entry: SIGHTING 10:08 AM – 11:17 AM — Acorn Woodpecker, Western Fence Lizard, Osprey, with three photos labeled &amp;quot;Acorn Woodpecker&amp;quot; (bird on bare branches against blue sky), &amp;quot;Wester...&amp;quot; (lizard on tree bark), and &amp;quot;Osprey&amp;quot; (nest on a utility pole), dated 1st May 2026. Third entry: SIGHTING 11:11 AM — White-crowned Sparrow, with a photo labeled &amp;quot;White-crowned Sparrow&amp;quot; of a sparrow with black and white striped head singing with open beak, dated 30th Apr 2026."&gt;&lt;/p&gt;
&lt;p&gt;I built this feature on my phone using Claude Code for web, as an extension of my &lt;a href="https://simonwillison.net/2026/Feb/20/beats/"&gt;beats system&lt;/a&gt; for syndicating external content. Here's &lt;a href="https://github.com/simonw/simonwillisonblog/pull/668"&gt;the PR&lt;/a&gt; and prompt.&lt;/p&gt;
&lt;p&gt;As with my other forms of incoming syndicated content sightings show up on the homepage, the date archive pages, and in site search results.&lt;/p&gt;
&lt;p&gt;I back-populated over a decade of iNaturalist sightings, which means you that if you &lt;a href="https://simonwillison.net/search/?q=lemur"&gt;search for lemur&lt;/a&gt; you'll see my lemur photos from Madagascar in 2019!


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/photography"&gt;photography&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/wildlife"&gt;wildlife&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/inaturalist"&gt;inaturalist&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/site-upgrades"&gt;site-upgrades&lt;/a&gt;&lt;/p&gt;



</summary><category term="blogging"/><category term="photography"/><category term="wildlife"/><category term="ai"/><category term="inaturalist"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude-code"/><category term="site-upgrades"/></entry><entry><title>The Zig project's rationale for their firm anti-AI contribution policy</title><link href="https://simonwillison.net/2026/Apr/30/zig-anti-ai/" rel="alternate"/><published>2026-04-30T01:24:23+00:00</published><updated>2026-04-30T01:24:23+00:00</updated><id>https://simonwillison.net/2026/Apr/30/zig-anti-ai/</id><summary type="html">
    &lt;p&gt;&lt;a href="https://ziglang.org/"&gt;Zig&lt;/a&gt; has one of the most stringent &lt;a href="https://ziglang.org/code-of-conduct/"&gt;anti-LLM policies&lt;/a&gt; of any major open source project:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;No LLMs for issues.&lt;/p&gt;
&lt;p&gt;No LLMs for pull requests.&lt;/p&gt;
&lt;p&gt;No LLMs for comments on the bug tracker, including translation. English is encouraged, but not required. You are welcome to post in your native language and rely on others to have their own translation tools of choice to interpret your words.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The most prominent project written in Zig may be the &lt;a href="https://bun.com/"&gt;Bun&lt;/a&gt; JavaScript runtime, which was &lt;a href="https://bun.com/blog/bun-joins-anthropic"&gt;acquired by Anthropic&lt;/a&gt; in December 2025 and, unsurprisingly, makes heavy use of AI assistance.&lt;/p&gt;
&lt;p&gt;Bun operates its own fork of Zig, and recently &lt;a href="https://x.com/bunjavascript/status/2048427636414923250"&gt;achieved a 4x performance improvement&lt;/a&gt; on Bun compile after adding "parallel semantic analysis and multiple codegen units to the llvm backend". Here's &lt;a href="https://github.com/oven-sh/zig/compare/upgrade-0.15.2%E2%80%A6upgrade-0.15.2-fast"&gt;that code&lt;/a&gt;. But &lt;a href="https://twitter.com/bunjavascript/status/2048428104893542781"&gt;@bunjavascript says&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We do not currently plan to upstream this, as Zig has a strict ban on LLM-authored contributions.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(Update: here's &lt;a href="https://ziggit.dev/t/bun-s-zig-fork-got-4x-faster-compilation-times/15183/19"&gt;a Zig core contributor&lt;/a&gt; providing details on why they wouldn't accept that particular patch independent of the LLM issue - parallel semantic analysis is a long planned feature but has implications "for the Zig language itself".)&lt;/p&gt;
&lt;p&gt;In &lt;a href="https://kristoff.it/blog/contributor-poker-and-ai/"&gt;Contributor Poker and Zig's AI Ban&lt;/a&gt; (&lt;a href="https://lobste.rs/s/ifcyr1/contributor_poker_zig_s_ai_ban"&gt;via Lobste.rs&lt;/a&gt;) Zig Software Foundation VP of Community Loris Cro explains the rationale for this strict ban. It's the best articulation I've seen yet for a blanket ban on LLM-assisted contributions:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;In successful open source projects you eventually reach a point where you start getting more PRs than what you’re capable of processing. Given what I mentioned so far, it would make sense to stop accepting imperfect PRs in order to maximize ROI from your work, but that’s not what we do in the Zig project. Instead, &lt;strong&gt;we try our best to help new contributors to get their work in, even if they need some help getting there&lt;/strong&gt;. We don’t do this just because it’s the “right” thing to do, but also &lt;strong&gt;because it’s the smart thing to do&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Zig values contributors over their contributions. Each contributor represents an investment by the Zig core team - the primary goal of reviewing and accepting PRs isn't to land new code, it's to help grow new contributors who can become trusted and prolific over time.&lt;/p&gt;
&lt;p&gt;LLM assistance breaks that completely. It doesn't matter if the LLM helps you submit a &lt;em&gt;perfect&lt;/em&gt; PR to Zig - the time the Zig team spends reviewing your work does nothing to help them add new, confident, trustworthy contributors to their overall project.&lt;/p&gt;
&lt;p&gt;Loris explains the name here:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The reason I call it “contributor poker” is because, just like people say about the actual card game, “you play the person, not the cards”. In contributor poker, you bet on the contributor, not on the contents of their first PR.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This makes a lot of sense to me. It relates to an idea I've seen circulating elsewhere: if a PR was mostly written by an LLM, why should a project maintainer spend time reviewing and discussing that PR as opposed to firing up their own LLM to solve the same problem?&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/zig"&gt;zig&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-ethics"&gt;ai-ethics&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/bun"&gt;bun&lt;/a&gt;&lt;/p&gt;



</summary><category term="javascript"/><category term="open-source"/><category term="ai"/><category term="zig"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="anthropic"/><category term="ai-ethics"/><category term="bun"/></entry><entry><title>Quoting Matthew Yglesias</title><link href="https://simonwillison.net/2026/Apr/28/matthew-yglesias/" rel="alternate"/><published>2026-04-28T13:25:29+00:00</published><updated>2026-04-28T13:25:29+00:00</updated><id>https://simonwillison.net/2026/Apr/28/matthew-yglesias/</id><summary type="html">
    &lt;blockquote cite="https://twitter.com/mattyglesias/status/2049105745132585161"&gt;&lt;p&gt;Five months in, I think I've decided that I don't want to vibecode — I want professionally managed software companies to use AI coding assistance to make more/better/cheaper software products that they sell to me for money.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p class="cite"&gt;&amp;mdash; &lt;a href="https://twitter.com/mattyglesias/status/2049105745132585161"&gt;Matthew Yglesias&lt;/a&gt;, in a now-deleted Tweet&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;&lt;/p&gt;



</summary><category term="agentic-engineering"/><category term="vibe-coding"/><category term="ai-assisted-programming"/><category term="ai"/></entry><entry><title>Adding a new content type to my blog-to-newsletter tool</title><link href="https://simonwillison.net/guides/agentic-engineering-patterns/adding-a-new-content-type/" rel="alternate"/><published>2026-04-18T03:15:36+00:00</published><updated>2026-04-18T03:15:36+00:00</updated><id>https://simonwillison.net/guides/agentic-engineering-patterns/adding-a-new-content-type/</id><summary type="html">
    &lt;p&gt;&lt;em&gt;&lt;a href="https://simonwillison.net/guides/agentic-engineering-patterns/"&gt;Agentic Engineering Patterns&lt;/a&gt; &amp;gt;&lt;/em&gt;&lt;/p&gt;
    &lt;p&gt;Here's an example of a deceptively short prompt that got a quite a lot of work done in a single shot.&lt;/p&gt;
&lt;p&gt;First, some background. I send out a &lt;a href="https://simonw.substack.com/"&gt;free Substack newsletter&lt;/a&gt; around once a week containing content copied-and-pasted from my blog. I'm effectively using Substack as a lightweight way to allow people to subscribe to my blog via email.&lt;/p&gt;
&lt;p&gt;I generate the newsletter with my &lt;a href="https://tools.simonwillison.net/blog-to-newsletter"&gt;blog-to-newsletter&lt;/a&gt; tool - an HTML and JavaScript app that fetches my latest content from &lt;a href="https://datasette.simonwillison.net/"&gt;this Datasette instance&lt;/a&gt; and formats it as rich text HTML, which I can then copy to my clipboard and paste into the Substack editor. Here's a &lt;a href="https://simonwillison.net/2023/Apr/4/substack-observable/"&gt;detailed explanation of how that works&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I recently &lt;a href="https://simonwillison.net/2026/Feb/20/beats/"&gt;added a new type of content&lt;/a&gt; to my blog to capture content that I post elsewhere, which I called "beats". These include things like releases of my open source projects, new tools that I've built, museums that I've visited (from &lt;a href="https://www.niche-museums.com/"&gt;niche-museums.com&lt;/a&gt;) and other external content.&lt;/p&gt;
&lt;p&gt;I wanted to include these in the generated newsletter. Here's the prompt I ran against the &lt;a href="https://github.com/simonw/tools"&gt;simonw/tools&lt;/a&gt; repository that hosts my &lt;code&gt;blog-to-newsletter&lt;/code&gt; tool, using &lt;a href="https://code.claude.com/docs/en/claude-code-on-the-web"&gt;Claude Code on the web&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;pre&gt;Clone simonw/simonwillisonblog from github to /tmp for reference

Update blog-to-newsletter.html to include beats that have descriptions - similar to how the Atom everything feed on the blog works

Run it with python -m http.server and use `uvx rodney --help` to test it - compare what shows up in the newsletter with what&amp;#x27;s on the homepage of https://simonwillison.net&lt;/pre&gt;
This got me the &lt;a href="https://github.com/simonw/tools/pull/268"&gt;exact solution&lt;/a&gt; I needed. Let's break down the prompt.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone simonw/simonwillisonblog from github to /tmp for reference&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I use this pattern a lot. Coding agents can clone code from GitHub, and the best way to explain a problem is often to have them look at relevant code. By telling them to clone to &lt;code&gt;/tmp&lt;/code&gt; I ensure they don't accidentally end up including that reference code in their own commit later on.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/simonw/simonwillisonblog"&gt;simonw/simonwillisonblog&lt;/a&gt; repository contains the source code for my Django-powered &lt;a href="https://simonwillison.net/"&gt;simonwillison.net&lt;/a&gt; blog. This includes the logic and database schema for my new "beats" feature.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Update blog-to-newsletter.html to include beats that have descriptions - similar to how the Atom everything feed on the blog works&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Referencing &lt;code&gt;blog-to-newsletter.html&lt;/code&gt; is all I need here to tell Claude which of the 200+ HTML apps in that &lt;code&gt;simonw/tools&lt;/code&gt; repo it should be modifying.&lt;/p&gt;
&lt;p&gt;Beats are automatically imported from multiple sources. Often they aren't very interesting - a dot-release bug fix for one of my smaller open source projects, for example.&lt;/p&gt;
&lt;p&gt;My blog includes a way for me to add additional descriptions to any beat, which provides extra commentary but also marks that beat as being more interesting than those that I haven't annotated in some way.&lt;/p&gt;
&lt;p&gt;I already use this as a distinction to decide which beats end up in my site's &lt;a href="https://simonwillison.net/about/#atom"&gt;Atom feed&lt;/a&gt;. Telling Claude to imitate that saves me from having to describe the logic in any extra detail.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Run it with python -m http.server and use `uvx rodney --help` to test it - compare what shows up in the newsletter with what's on the homepage of https://simonwillison.net&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Coding agents always work best if they have some kind of validation mechanism they can use to test their own work.&lt;/p&gt;
&lt;p&gt;In this case I wanted Claude Code to actively check that the changes it made to my tool would correctly fetch and display the latest data.&lt;/p&gt;
&lt;p&gt;I reminded it to use &lt;code&gt;python -m http.server&lt;/code&gt; as a static server because I've had issues in the past with applications that fetch data and break when served as a file from disk instead of a localhost server. In this particular case that may not have been necessary, but my prompting muscle memory has &lt;code&gt;python -m http.server&lt;/code&gt; baked in at this point!&lt;/p&gt;
&lt;p&gt;I described the &lt;code&gt;uvx rodney --help&lt;/code&gt; trick in &lt;a href="https://simonwillison.net/guides/agentic-engineering-patterns/agentic-manual-testing/#using-browser-automation-for-web-uis"&gt;the agentic manual testing chapter&lt;/a&gt;. Rodney is browser automation software that can be installed using &lt;code&gt;uvx&lt;/code&gt;, and that has &lt;code&gt;--help&lt;/code&gt; output designed to teach an agent everything it needs to know in order to use the tool.&lt;/p&gt;
&lt;p&gt;I figured that telling Claude to compare the results in the newsletter to the content of my blog's homepage would be enough for it to confidently verify that the new changes were working correctly, since I had recently posted content that matched the new requirements.&lt;/p&gt;
&lt;p&gt;You can see &lt;a href="https://claude.ai/code/session_01BibYBuvJi2qNUyCYGaY3Ss"&gt;the full session here&lt;/a&gt;, or if that doesn't work I have an &lt;a href="https://gisthost.github.io/?e906e938100ab42f4d6a932505219324/page-001.html#msg-2026-04-18T00-13-57-081Z"&gt;alternative transcript&lt;/a&gt; showing all of the individual tool calls.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/simonw/tools/pull/268"&gt;resulting PR&lt;/a&gt; made exactly the right change. It added an additional UNION clause to the SQL query that fetched the blog's content, filtering out draft beats and beats that have nothing in their &lt;code&gt;note&lt;/code&gt; column:&lt;/p&gt;
&lt;p&gt;&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;union&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;all&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s1"&gt;'beat'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s1"&gt;'No HTML'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;json_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s1"&gt;'beat_type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;beat_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s1"&gt;'commentary'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;commentary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s1"&gt;'note'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;note&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="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;external_url&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;blog_beat&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;is_draft&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;union&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;all&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
And it figured out a mapping of beat types to their formal names, presumably derived from the &lt;a href="https://github.com/simonw/simonwillisonblog/blob/2e9d7ebe64da799b3927e61b4f85d98f7e9bc9aa/blog/models.py#L545-L551"&gt;Django ORM definition&lt;/a&gt; that it read while it was exploring the reference codebase:
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;const beatTypeDisplay = {
  release: 'Release',
  til: 'TIL',
  til_update: 'TIL updated',
  research: 'Research',
  tool: 'Tool',
  museum: 'Museum'
};
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
Telling agents to use another codebase as reference is a powerful shortcut for communicating complex concepts with minimal additional information needed in the prompt.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-engineering"&gt;prompt-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="ai"/><category term="llms"/><category term="prompt-engineering"/><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="agentic-engineering"/><category term="github"/></entry><entry><title>datasette PR #2689: Replace token-based CSRF with Sec-Fetch-Site header protection</title><link href="https://simonwillison.net/2026/Apr/14/replace-token-based-csrf/" rel="alternate"/><published>2026-04-14T23:58:53+00:00</published><updated>2026-04-14T23:58:53+00:00</updated><id>https://simonwillison.net/2026/Apr/14/replace-token-based-csrf/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette/pull/2689"&gt;datasette PR #2689: Replace token-based CSRF with Sec-Fetch-Site header protection&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Datasette has long protected against CSRF attacks using CSRF tokens, implemented using my &lt;a href="https://github.com/simonw/asgi-csrf"&gt;asgi-csrf&lt;/a&gt; Python library. These are something of a pain to work with - you need to scatter forms in templates with &lt;code&gt;&amp;lt;input type="hidden" name="csrftoken" value="{{ csrftoken() }}"&amp;gt;&lt;/code&gt; lines and then selectively disable CSRF protection for APIs that are intended to be called from outside the browser.&lt;/p&gt;
&lt;p&gt;I've been following Filippo Valsorda's research here with interest, described in &lt;a href="https://words.filippo.io/csrf/"&gt;this detailed essay from August 2025&lt;/a&gt; and shipped &lt;a href="https://tip.golang.org/doc/go1.25#nethttppkgnethttp"&gt;as part of Go 1.25&lt;/a&gt; that same month.&lt;/p&gt;
&lt;p&gt;I've now landed the same change in Datasette. Here's the PR description - Claude Code did much of the work (across 10 commits, closely guided by me and cross-reviewed by GPT-5.4) but I've decided to start writing these PR descriptions by hand, partly to make them more concise and also as an exercise in keeping myself honest.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New CSRF protection middleware inspired by Go 1.25 and &lt;a href="https://words.filippo.io/csrf/"&gt;this research&lt;/a&gt; by Filippo Valsorda. This replaces the old CSRF token based protection.&lt;/li&gt;
&lt;li&gt;Removes all instances of &lt;code&gt;&amp;lt;input type="hidden" name="csrftoken" value="{{ csrftoken() }}"&amp;gt;&lt;/code&gt; in the templates - they are no longer needed.&lt;/li&gt;
&lt;li&gt;Removes the &lt;code&gt;def skip_csrf(datasette, scope):&lt;/code&gt; plugin hook defined in &lt;code&gt;datasette/hookspecs.py&lt;/code&gt; and its documentation and tests.&lt;/li&gt;
&lt;li&gt;Updated &lt;a href="https://docs.datasette.io/en/latest/internals.html#csrf-protection"&gt;CSRF protection documentation&lt;/a&gt; to describe the new approach.&lt;/li&gt;
&lt;li&gt;Upgrade guide now &lt;a href="https://docs.datasette.io/en/latest/upgrade_guide.html#csrf-protection-is-now-header-based"&gt;describes the CSRF change&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/csrf"&gt;csrf&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;&lt;/p&gt;



</summary><category term="csrf"/><category term="security"/><category term="datasette"/><category term="ai-assisted-programming"/></entry></feed>