<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: Entries</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/atom/entries/" rel="self"/><id>http://simonwillison.net/</id><updated>2026-07-31T23:13:22+00:00</updated><author><name>Simon Willison</name></author><entry><title>Stateless MCP has recaptured my interest (and inspired mcp-explorer and datasette-mcp)</title><link href="https://simonwillison.net/2026/Jul/31/stateless-mcp/#atom-entries" rel="alternate"/><published>2026-07-31T23:13:22+00:00</published><updated>2026-07-31T23:13:22+00:00</updated><id>https://simonwillison.net/2026/Jul/31/stateless-mcp/#atom-entries</id><summary type="html">&lt;p&gt;Tuesday was &lt;a href="https://x.com/ade_oshineye/status/2082129440943866149"&gt;Stateless MCP day&lt;/a&gt; - the rollout of MCP 2.0, or &lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28/"&gt;the 2026-07-28 Model Context Protocol specification&lt;/a&gt; to use the more formal but less memorable name. This is the most significant change to the MCP spec since it first launched, and has also served to reignite my personal interest in the protocol.&lt;/p&gt;
&lt;p&gt;For background: MCP is the Model Context Protocol, which describes a standard way to expose new tools to LLM-powered agent frameworks. It was introduced by Anthropic back &lt;a href="https://www.anthropic.com/news/model-context-protocol"&gt;in November 2024&lt;/a&gt;, had a &lt;em&gt;huge&lt;/em&gt; spike of interest through much of 2025, and then became somewhat eclipsed by &lt;a href="https://simonwillison.net/2025/Oct/16/claude-skills/"&gt;Skills&lt;/a&gt; (another Anthropic invention) when it became apparent that an agent harness with access to a terminal and &lt;code&gt;curl&lt;/code&gt; could do most of what MCP did in a more flexible way. I wrote about that &lt;a href="https://simonwillison.net/2025/Dec/31/the-year-in-llms/#the-only-year-of-mcp"&gt;in my review of 2025&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I'm coming back around to MCP now. Giving an agent a shell environment with the ability to access the internet is &lt;a href="https://simonwillison.net/2026/Jul/22/openai-cyberattack/"&gt;fraught with risk&lt;/a&gt;, and requires a strong model that is capable of effectively driving such an environment. MCP tools are easier to audit and control, and simple enough that smaller models that run on a laptop can still drive them reasonably well.&lt;/p&gt;
&lt;p&gt;The new stateless MCP specification also greatly decreases the complexity of implementing both clients and servers for the protocol. I built three of those this week!&lt;/p&gt;
&lt;h4 id="what-s-easier-with-stateless-mcp"&gt;What's easier with stateless MCP&lt;/h4&gt;
&lt;p&gt;The best demonstration of the difference between stateful and stateless MCP is in this &lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/"&gt;May 21st blog post&lt;/a&gt; that introduced the RC for the new specification. It included a clear before-and-after example.&lt;/p&gt;
&lt;p&gt;The older stateful MCP (I'm going to call it "legacy MCP") required two HTTP requests - the first to initialize a session and obtain a &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, and the second to actually call the tool:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /mcp HTTP/1.1
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
    },
    "clientInfo": {
      "name": "my-app",
      "version": "1.0"
    }
  }
}

POST /mcp HTTP/1.1
Mcp-Session-Id: 1868a90c-3a3f-4f5b
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "q": "otters"
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The new stateless way uses a single HTTP request which looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "q": "otters"
    },
    "_meta": {
      "io.modelcontextprotocol/clientInfo": {
        "name": "my-app",
        "version": "1.0"
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is so much cleaner from both a client- and server-side implementation perspective. It's also a better fit for building scalable web applications, since now you don't need to maintain server-side state to keep track of those session IDs, or worry about routing the same session to the same backend machine.&lt;/p&gt;
&lt;h4 id="mcp-explorer"&gt;mcp-explorer&lt;/h4&gt;
&lt;p&gt;I couldn't find a great CLI tool for interactively probing an MCP server, so I had Codex help build my own.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/mcp-explorer"&gt;mcp-explorer&lt;/a&gt;&lt;/strong&gt; is the result. It's a stateless Python CLI tool, so you don't even need to install it to try it out - it works with &lt;a href="https://docs.astral.sh/uv/guides/tools/#running-tools"&gt;uvx&lt;/a&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx mcp-explorer list https://agentic-mermaid.dev/mcp&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This queries Ade Oshineye's &lt;a href="https://agentic-mermaid.dev/"&gt;agentic-mermaid.dev&lt;/a&gt; demo MCP. The above command returns the following list of tools:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;execute(code: string, timeoutMs?: integer) - Execute Mermaid SDK code
  Run JavaScript in an isolated sandbox; return a value.

describe_sdk(family: string, detail?: string) - Describe Mermaid SDK operations
  Return version-matched mutation operations for one diagram family.

render_svg(source: string, options?: object) - Render Mermaid as SVG
  Render a Mermaid source string to themeable SVG. Returns { ok, svg }.

render_ascii(source: string, useAscii?: boolean, targetWidth?: integer, options?: object) - Render Mermaid as text
  Render a Mermaid source string to text. Returns { ok, text }.

render_png(source: string, scale?: number, background?: string, fitTo?: object, options?: object) - Render Mermaid as PNG
  Rasterize a Mermaid source string to PNG. Returns { ok, png_base64 }.
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then to inspect a tool:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx mcp-explorer inspect render_svg&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This outputs a whole bunch of information, including the JSON schema of the inputs and outputs.&lt;/p&gt;
&lt;p&gt;To call that tool and pass arguments to it:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx mcp-explorer call \
  https://agentic-mermaid.dev/mcp \
  render_svg \
  -a &lt;span class="pl-c1"&gt;source&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;graph TD; A--&amp;gt;B&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; \
  -a options &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{"padding":24}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Which returns:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{"ok":true,"svg":"&amp;lt;svg xmlns=\"http://www.w3.org/2000/svg\" width=...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To get just the raw SVG try adding &lt;code&gt;| jq .svg -r&lt;/code&gt; to that command. I got back &lt;a href="https://gist.github.com/simonw/b07c62f0ce103be6932477659d5dd1ac"&gt;this image&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/mermaid-example.svg" alt="SVG of as A box on top of a B box with an arrow from A to B" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;There are a &lt;a href="https://github.com/simonw/mcp-explorer/blob/main/README.md"&gt;few more commands&lt;/a&gt; in the README, but you get the general idea. I find building CLI tools like this to be a really productive way to get familiar with a specification, even if an agent writes most of the actual code.&lt;/p&gt;
&lt;h4 id="datasette-mcp"&gt;datasette-mcp&lt;/h4&gt;
&lt;p&gt;The second project is &lt;strong&gt;&lt;a href="https://github.com/datasette/datasette-mcp"&gt;datasette-mcp&lt;/a&gt;&lt;/strong&gt;, a Datasette plugin which adds a &lt;code&gt;/-/mcp&lt;/code&gt; endpoint to any Datasette instance.&lt;/p&gt;
&lt;p&gt;This is probably the fourth time I've tried building this plugin, but thanks to the new stateless MCP specification I finally have a version that feels good to release.&lt;/p&gt;
&lt;p&gt;It provides just three tools: &lt;code&gt;list_databases()&lt;/code&gt;, &lt;code&gt;get_database_schema(database_name)&lt;/code&gt;, and &lt;code&gt;execute_sql(database_name, sql)&lt;/code&gt;. They do exactly what you would expect them to do - though &lt;code&gt;execute_sql()&lt;/code&gt; is read-only for the moment.&lt;/p&gt;
&lt;p&gt;Wire these into an agent, or a chat tool like ChatGPT or Claude, and they'll gain the ability to run SQL queries against your hosted Datasette instance.&lt;/p&gt;
&lt;p&gt;So far I'm running it on the Datasette mirror of my blog, at &lt;a href="datasette.simonwillison.net/-/mcp"&gt;datasette.simonwillison.net/-/mcp&lt;/a&gt;. It took a bit of fiddling to figure out how to attach that to ChatGPT and Claude, but I got there in the end. Here's &lt;a href="https://til.simonwillison.net/llms/mcp-in-claude-and-chatgpt"&gt;a new TIL&lt;/a&gt; showing exactly how to do that.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://claude.ai/share/de1ad9bf-f7c2-4fb9-a9a0-2a1ae39995db"&gt;a shared Claude session&lt;/a&gt; where I asked it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;list tables in simonwillison.net&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And then:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;what has Simon said recently about MCP?&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It ran 7 separate SQL queries to figure out the answer.&lt;/p&gt;
&lt;h4 id="llm-mcp-client"&gt;llm-mcp-client&lt;/h4&gt;
&lt;p&gt;My &lt;a href="https://llm.datasette.io/"&gt;LLM tool&lt;/a&gt; is long overdue for an official MCP integration. The new alpha &lt;a href="https://github.com/simonw/llm-mcp-client"&gt;llm-mcp-client&lt;/a&gt; plugin is my attempt at exactly that:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-mcp-client
llm -T &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;MCP("https://datasette.simonwillison.net/-/mcp")&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;count the notes&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's the output (including reasoning trace, I'm using &lt;a href="https://simonwillison.net/2026/Jul/30/llm-rc2/"&gt;LLM 0.32rc2&lt;/a&gt;):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Considering note count&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;I see the question "count the notes" is probably asking me to tally up blog notes. It could also mean published notes or drafts, so there's some ambiguity there. I'll need to figure out the total number of notes, likely by querying the count for both published notes and drafts to get a clear answer. Let's execute that count!&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;There are &lt;strong&gt;151 notes&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And &lt;a href="https://gist.github.com/simonw/4e8f558766150658ce35eab4f0fc3e04"&gt;the output of llm logs&lt;/a&gt; for that prompt.&lt;/p&gt;
&lt;p&gt;Once this is fully baked, I'm considering bringing it directly into LLM core. I'm excited to experiment with MCP in &lt;a href="https://agent.datasette.io/"&gt;Datasette Agent&lt;/a&gt; and &lt;a href="https://github.com/simonw/llm-coding-agent"&gt;llm-coding-agent&lt;/a&gt; as well.&lt;/p&gt;
&lt;h4 id="mcp-is-a-safer-way-to-build-with-agents"&gt;MCP is a safer way to build with agents&lt;/h4&gt;
&lt;p&gt;A few months after MCP was first released, I wrote &lt;a href="https://simonwillison.net/2025/Apr/9/mcp-prompt-injection/"&gt;Model Context Protocol has prompt injection security problems&lt;/a&gt;, where I noted that the pattern of having end users mix and match tools pushed responsibility for avoiding data exfiltration attacks out to the users themselves. I hadn't coined &lt;a href="https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/"&gt;the Lethal Trifecta&lt;/a&gt; yet, but that was absolutely what I had in mind.&lt;/p&gt;
&lt;p&gt;Then general agents with arbitrary shell and &lt;code&gt;curl&lt;/code&gt; access came along, and that's so much harder to keep secure!&lt;/p&gt;
&lt;p&gt;Something I've come to appreciate about MCP is that it's much easier to reason about agent capabilities and what might go wrong than with arbitrary command execution in an open network environment - the default for most of today's general and coding agent tools.&lt;/p&gt;
&lt;p&gt;I plan to lean into MCP a whole lot more when I'm building sensitive applications on top of LLMs.&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="projects"/><category term="ai"/><category term="datasette"/><category term="mermaid"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="anthropic"/><category term="model-context-protocol"/></entry><entry><title>OpenAI’s accidental cyberattack against Hugging Face is science fiction that happened</title><link href="https://simonwillison.net/2026/Jul/22/openai-cyberattack/#atom-entries" rel="alternate"/><published>2026-07-22T23:51:33+00:00</published><updated>2026-07-22T23:51:33+00:00</updated><id>https://simonwillison.net/2026/Jul/22/openai-cyberattack/#atom-entries</id><summary type="html">&lt;p&gt;This story is wild. The short version: OpenAI were running a cybersecurity test against an unreleased model, with the model's guardrail features turned off. Rather than solve the test, the model broke its way out of OpenAI's sandbox, then found exploits to break &lt;em&gt;in&lt;/em&gt; to Hugging Face, all so it could cheat on the test by stealing the answers.&lt;/p&gt;
&lt;p&gt;Along the way it helped make the strongest case yet for how the imbalance of model availability is hurting our ability to secure our software.&lt;/p&gt;
&lt;h4 id="here-s-what-happened"&gt;Here's what happened&lt;/h4&gt;
&lt;p&gt;We currently have three documents to help us understand what happened here.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2605.11086"&gt;ExploitGym: Can AI Agents Turn Security Vulnerabilities into Real Attacks?&lt;/a&gt; is a paper published on 11th May 2026 describing ExploitGym, a new eval suite for LLM-powered agent systems.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/blog/security-incident-july-2026"&gt;Security incident disclosure — July 2026&lt;/a&gt; by Hugging Face on 16th July 2026 describes how they detected an attack from an "agentic security-research harness - used LLM still not known" that breached some of their systems.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openai.com/index/hugging-face-model-evaluation-security-incident/"&gt;OpenAI and Hugging Face partner to address security incident during model evaluation&lt;/a&gt; from OpenAI on 21st July 2026 confesses that it was &lt;em&gt;their&lt;/em&gt; agent harness that did this, and that they're working with Hugging Face to clean up the mess.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id="exploitgym"&gt;ExploitGym&lt;/h4&gt;
&lt;p&gt;I hadn't seen the &lt;a href="https://arxiv.org/abs/2605.11086"&gt;ExploitGym paper&lt;/a&gt; before and it's a really interesting one. Authors from UC Berkeley, the Max Planck Institute, UC Santa Barbara, and Arizona State designed a new benchmark for evaluating models on their ability to turn a reported vulnerability into a concrete exploit. OpenAI, Anthropic, and Google provided feedback and helped run the benchmark against their models.&lt;/p&gt;
&lt;p&gt;The benchmark "comprises 898 instances derived from real-world vulnerabilities that affected popular software projects" - including the Linux kernel and V8 JavaScript engine. The ExploitGym benchmark is &lt;a href="https://github.com/sunblaze-ucb/exploitgym"&gt;available on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here's the paragraph that best represents their benchmark results:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Among all configurations, Claude Mythos Preview and GPT-5.5 achieve the highest success counts (157 and 120 successes, respectively), demonstrating that current frontier agents can exploit a substantial subset of real-world vulnerabilities under controlled conditions. GPT-5.4 also solves a notable 54 tasks, placing it in an intermediate tier. The remaining model–agent pairings solve fewer than 15 tasks each, underscoring that end-to-end exploitation remains challenging and sharply differentiates today’s frontier systems. Notably, Claude Opus 4.7 achieves fewer successes than Claude Opus 4.6 despite being a newer checkpoint, and does so at substantially lower cost on the full set. Trace inspection reveals that Claude Opus 4.7 and Gemini 3.1 Pro frequently conclude early after judging the target vulnerability non-exploitable.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The paper also describes the approach they took to preventing the agents from cheating by going outside the parameters of the test. This becomes relevant in a moment!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Outbound connections are restricted to a curated allowlist that permits routine package installation (Ubuntu apt repositories and PyPI) and fetching the toolchains required for building V8. All other external endpoints are blocked.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The paper concludes with this (emphasis mine):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Our results show that &lt;strong&gt;autonomous exploit development by frontier AI agents is no longer a hypothetical capability&lt;/strong&gt;. While current agents are not yet reliable across all targets, they already &lt;strong&gt;exploit a non-trivial fraction of real-world vulnerabilities&lt;/strong&gt;, including complex targets such as kernel components. This rapid emergence is itself a central finding, showing that capabilities that would have seemed implausible are now present in deployed frontier models.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;An important detail here: this paper isn't about discovering vulnerabilities; it's about being able to take those vulnerabilities and turn them into working exploits.&lt;/p&gt;
&lt;p&gt;When Anthropic first restricted access to Mythos &lt;a href="https://simonwillison.net/2026/Apr/7/project-glasswing/"&gt;back in April&lt;/a&gt; they talked about this capability as well. A model that can act on vulnerabilities is a lot more dangerous than one that can just discover them.&lt;/p&gt;
&lt;p&gt;One of the ways Fable differs from Mythos is that it's more likely to refuse to weaponize vulnerabilities in this way. I get the impression the US government did not understand that distinction when they banned Fable &lt;a href="https://simonwillison.net/2026/Jun/16/fable-5-export-controls/"&gt;last month&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="the-hugging-face-incident"&gt;The Hugging Face incident&lt;/h4&gt;
&lt;p&gt;The first hint we got of the attack was in &lt;a href="https://huggingface.co/blog/security-incident-july-2026"&gt;this blog post by Hugging Face&lt;/a&gt; on 16th July 2026:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A malicious dataset abused two code-execution paths in our dataset processing (a remote-code dataset loader and a template-injection in a dataset configuration) to run code on a processing worker. From there, the actor escalated to node-level access, harvested cloud and cluster credentials, and moved laterally into several internal clusters over a weekend.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I hope they release more details about the code that pulled this off. I'm assuming this means packages using the &lt;a href="https://github.com/huggingface/datasets"&gt;datasets library&lt;/a&gt;, a Hugging Face project for bundling up and sharing datasets on their platform. That library used to execute arbitrary code but has been steadily locked down over time, with the &lt;a href="https://github.com/huggingface/datasets/releases/tag/4.0.0"&gt;4.0.0 release&lt;/a&gt; in July 2025 removing the &lt;code&gt;trust_remote_code=True&lt;/code&gt; flag entirely.&lt;/p&gt;
&lt;p&gt;Assuming the attack used that library it must have either abused pickle serialization in some way, found some other non-obvious code execution path, or (most likely) specified &lt;code&gt;datasets&amp;lt;4.0.0&lt;/code&gt; as the dependency.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The campaign was run by an autonomous agent framework (appearing to be built on an agentic security-research harness - used LLM still not known) executing many thousands of individual actions across a swarm of short-lived sandboxes, with self-migrating command-and-control staged on public services.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This was a sophisticated attack!&lt;/p&gt;
&lt;p&gt;Then Hugging Face hit a wall: they tried to use "frontier models behind commercial APIs" - I'm guessing from Anthropic and OpenAI - to help analyze the attack, and were blocked:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;When we started the log analysis, we first used frontier models behind commercial APIs. This did not work: the analysis requires submitting large volumes of real attack commands, exploit payloads, and C2 artifacts, and these requests were blocked by the providers' safety guardrails, which cannot distinguish an incident responder from an attacker.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;They switched to their own self-hosted instance of MIT licensed GLM-5.2 and it helped them figure out what was going on.&lt;/p&gt;
&lt;p&gt;This indicated a fundamental asymmetry between the defending team and the (so-far unknown) attacker:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We do not know which model powered the attacker's agents, whether a jailbroken hosted model or an unrestricted open-weight one; either way, the attacker was bound by no usage policy, while our own forensic work was blocked by the guardrails of the hosted models we first tried.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;As a useful indicator of how seriously they took the attack:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[...] Finally, we have also reported this incident to law enforcement agencies.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So who was responsible for this "autonomous agent framework"? It turned out to be OpenAI themselves.&lt;/p&gt;
&lt;h4 id="the-openai-confession"&gt;The OpenAI confession&lt;/h4&gt;
&lt;p&gt;Five days later, &lt;a href="https://openai.com/index/hugging-face-model-evaluation-security-incident/"&gt;on July 21st&lt;/a&gt;, OpenAI revealed the culprit. They had been running the ExploitGym benchmark against a new, as-yet undisclosed model, and that model had been operating &lt;em&gt;way&lt;/em&gt; outside its intended parameters (emphasis mine):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;After investigating, we now know &lt;strong&gt;that this particular incident was driven by a combination of OpenAI models&lt;/strong&gt; — including GPT‑5.6 Sol and an even more capable pre-release model, all with reduced cyber refusals for evaluation purposes — while being internally tested on a &lt;a href="https://arxiv.org/abs/2605.11086"&gt;benchmark⁠&lt;/a&gt; [ExploitGym] of cyber capabilities. [...]&lt;/p&gt;
&lt;p&gt;We estimate maximal cyber capabilities by &lt;strong&gt;running this evaluation without production classifiers used to prevent models from pursuing high-risk cyber activity&lt;/strong&gt;. Our benchmarks run in a highly isolated environment, with network access constrained to the ability to install packages through an internally hosted third-party software that acts as a proxy and cache for package registries.&lt;/p&gt;
&lt;p&gt;The models &lt;strong&gt;identified and chained vulnerabilities across OpenAI’s research environment and Hugging Face’s production infrastructure&lt;/strong&gt; to obtain test solutions directly from Hugging Face’s production database. All evidence suggests that the models were hyperfocused on finding a solution for ExploitGym, going to extreme lengths to achieve a rather narrow testing goal.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It's pretty clear what happened here. OpenAI removed safety filters for an in-progress model, locked it up in a sandbox and told it to solve the ExploitGym problems. Given the absence of guardrails there was nothing to prevent the model from attempting to break out of that sandbox, break into Hugging Face, and read the answers from there instead.&lt;/p&gt;
&lt;p&gt;OpenAI's sandbox used the now common pattern of allowing access to an allow-list of sites related to installing additional packages via a controlled proxy. In OpenAI's words:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;While operating in our sandboxed testing environment, our models &lt;strong&gt;spent a substantial amount of inference compute finding a way to obtain open Internet access&lt;/strong&gt;, in pursuit of solving the evaluation problem. To gain access, the models identified and exploited &lt;strong&gt;a zero-day vulnerability (which we’ve now responsibly disclosed to the vendor) in the package registry cache proxy&lt;/strong&gt;. With this access, our models performed a series of privilege escalation and lateral movement actions in our research testing environment until the models reached a node with Internet access.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So step one was to break out onto the public internet. The model then broke into Hugging Face to find the answers:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;After gaining Internet access, the models &lt;strong&gt;inferred that Hugging Face potentially hosted models, datasets and solutions&lt;/strong&gt; for ExploitGym. Knowing this, the model searched for and successfully found ways to gain access to secret information that it could use to cheat the evaluation. In one example, &lt;strong&gt;the model chained together multiple attack vectors, including using stolen credentials and zero-day vulnerabilities&lt;/strong&gt; to find a remote code execution path on the Hugging Face servers.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Chaining together multiple attack vectors is &lt;em&gt;exactly&lt;/em&gt; the kind of thing these new models can do, where previous generations of models might have failed.&lt;/p&gt;
&lt;p&gt;I wrote last month about how &lt;a href="https://simonwillison.net/2026/Jun/11/fable-is-relentlessly-proactive/"&gt;Claude Fable is relentlessly proactive&lt;/a&gt;, when I noticed it spinning up custom web servers and deploying CORS tricks on my own laptop just to help debug a WebKit CSS issue. It turns out relentless proactivity is the defining trait of this new generation of Mythos-class models. If you set them a goal and give them a way to get there, even inadvertently, they &lt;em&gt;will figure it out&lt;/em&gt;.&lt;/p&gt;
&lt;h4 id="resist-the-temptation-to-write-this-off-as-a-stunt"&gt;Resist the temptation to write this off as a stunt&lt;/h4&gt;
&lt;p&gt;There will inevitably be some people who dismiss this story as a dishonest marketing trick by OpenAI to make their models sound terrifyingly effective. I found 81 instances of the term "marketing" in &lt;a href="https://news.ycombinator.com/item?id=48997548"&gt;the Hacker News discussion&lt;/a&gt; of the incident.&lt;/p&gt;
&lt;p&gt;To those people I say &lt;em&gt;pull your heads out of the sand&lt;/em&gt; - you're now including Hugging Face in your conspiracy theories, just so you can deny the crescendo of evidence here!&lt;/p&gt;
&lt;p&gt;The best models we have today have the ability to both find and exploit new vulnerabilities. The ExploitGym paper itself concludes that "autonomous exploit development by frontier AI agents is no longer a hypothetical capability", and this incident is a perfect example of exactly that.&lt;/p&gt;
&lt;h4 id="the-asymmetry-is-increasingly-frustrating"&gt;The asymmetry is increasingly frustrating&lt;/h4&gt;
&lt;p&gt;One of the most infuriating details of this story is how Hugging Face, faced with an accidental and aggressive attack from one of OpenAI's models, were unable to then turn to OpenAI's models to help them fend off the attack.&lt;/p&gt;
&lt;p&gt;The frontier models we have access to are increasingly being constrained in how much they can help us protect our software, heavily influenced by the US government's ongoing threat of export controls.  Claude Fable 5 wouldn't even &lt;a href="https://simonwillison.net/guides/agentic-engineering-patterns/prompts/#proofreader"&gt;proofread this article&lt;/a&gt; for me! It insisted on downgrading me to a less capable model.&lt;/p&gt;
&lt;p&gt;Meanwhile open weight models from China such as GLM-5.2, Kimi 3 and the new Qwen 3.8 Max appear to have none of these restrictions - and any restrictions that &lt;em&gt;do&lt;/em&gt; exist can likely be fine-tuned out of them by modifying the weights&lt;/p&gt;
&lt;p&gt;These constraints are meant to make us safer. I think there's a risk that they are having the opposite effect.&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="sandboxing"/><category term="security"/><category term="ai"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="hugging-face"/><category term="anthropic"/><category term="paper-review"/><category term="ai-security-research"/><category term="openai-hugging-face-incident"/></entry><entry><title>A Fireside Chat with Cat and Thariq from the Claude Code team</title><link href="https://simonwillison.net/2026/Jul/21/cat-and-thariq/#atom-entries" rel="alternate"/><published>2026-07-21T12:54:02+00:00</published><updated>2026-07-21T12:54:02+00:00</updated><id>https://simonwillison.net/2026/Jul/21/cat-and-thariq/#atom-entries</id><summary type="html">&lt;p&gt;Earlier this month I hosted a fireside chat session at the &lt;a href="https://www.ai.engineer/worldsfair/2026"&gt;AI Engineer World's Fair&lt;/a&gt; with Cat Wu and Thariq Shihipar from Anthropic's Claude Code team. We talked about Claude Code, Claude Tag, Fable, coding agent security, evals, tool design, and how Anthropic use these tools themselves.&lt;/p&gt;
&lt;p&gt;The full video of the session is now available &lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g"&gt;on YouTube&lt;/a&gt;. Below is an edited copy of the transcript, with extra links and my own bolded highlights.&lt;/p&gt;
&lt;iframe style="margin-top: 0.5em; margin-bottom: 1em;" width="560" height="315" src="https://www.youtube-nocookie.com/embed/uU5Gv2h8-9g" title="SimonThis Year in Claude" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="allowfullscreen"&gt; &lt;/iframe&gt;

&lt;p&gt;A few top-level notes if you don't want to watch the video or wade through the whole transcript:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Claude Tag (Claude's new collaborative Slack integration) now lands &lt;strong&gt;65% of the product engineering PRs&lt;/strong&gt; for the Claude Code team.&lt;/li&gt;
&lt;li&gt;Claude Code ships features to Anthropic employees first, and &lt;strong&gt;only ships the features that demonstrate user retention with that cohort&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Critical changes to Claude Code are still reviewed manually, but the team increasingly relies on automated code review for the "outer layers" of the product.&lt;/li&gt;
&lt;li&gt;Adding examples to a system prompt is &lt;strong&gt;no longer best practice&lt;/strong&gt; for models like Fable 5 or even Opus 4.8. The Claude Code system prompt recently &lt;strong&gt;reduced in size by 80%&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Likewise, lists of "&lt;strong&gt;don't do X and don't do Y&lt;/strong&gt;" can reduce the quality of results from the latest models.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://en.wikipedia.org/wiki/Eating_your_own_dog_food"&gt;Dogfooding&lt;/a&gt; inside Anthropic is called "&lt;strong&gt;ant fooding&lt;/strong&gt;".&lt;/li&gt;
&lt;li&gt;Anthropic &lt;strong&gt;really believe in their &lt;a href="https://code.claude.com/docs/en/auto-mode-config"&gt;auto mode&lt;/a&gt;&lt;/strong&gt;, and see that as an enabling technology for Claude Tag.&lt;/li&gt;
&lt;li&gt;Thariq advises offsetting coding-agent-induced &lt;a href="https://simonwillison.net/2026/Feb/15/deep-blue/"&gt;Deep Blue&lt;/a&gt; by "&lt;strong&gt;being more ambitious&lt;/strong&gt;" with the work you take on.&lt;/li&gt;
&lt;li&gt;Fable is &lt;strong&gt;competent at editing video&lt;/strong&gt;, and Thariq &lt;a href="https://twitter.com/trq212/status/2064826394589442448"&gt;used it&lt;/a&gt; to edit its own launch video.&lt;/li&gt;
&lt;li&gt;Anthropic's culture of working (internally) in public is key to their success, as demonstrated by the way they use Claude Tag in their public Slack Channels.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="how-has-what-you-do-day-to-day-changed-in-the-past-year-"&gt;How has what you do day-to-day changed in the past year?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=65s"&gt;1:05&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Claude Code came out in February of last year — it's under a year and a half old, and it was originally just a bullet point on &lt;a href="https://www.anthropic.com/news/claude-3-7-sonnet"&gt;the Claude Sonnet 3.7 launch&lt;/a&gt;. &lt;strong&gt;How has what you do on a day-to-day basis changed in the past year&lt;/strong&gt;, now that we have these coding agents that actually work for us?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; I remember when we first came out with Claude Code and Sonnet 3.7, you would give it a task and you would have to closely monitor every single little thing it tried to do. I would read every permission prompt extremely carefully. I would frequently say no — no, no, no, did you check this file? Did you check that file? And now it's been incredible with every model generation. I feel like &lt;strong&gt;we've all gotten a chance to take a step back and delegate a lot more of the menial implementation to Claude&lt;/strong&gt;. It's freed up a lot of our time to think about more creative work, like: what is the right experience that we should be providing to our users, now that we know Claude Code can implement a lot of it? And now with Fable it's a totally different step change improvement. &lt;strong&gt;We see for a lot of our use cases that you can actually one-shot a ton of features with Fable now&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; I remember the first text I got about Claude Code. One of my best friends was like, "You need to go try Claude Code." It was about when Opus 4 came out, and I tried it and I was like, "Oh, shit. I need to work at Anthropic now." And that was Opus 4 — great model, but you were reading permission prompts. It's kind of crazy how much amnesia we have, where I'm like, oh, auto mode has always been here, right? I don't even remember pressing yes and allow. For me, the big thing I'm trying to push myself on is that &lt;strong&gt;we have to do higher quality work than we've ever done before&lt;/strong&gt;. The outputs are incredibly high quality. &lt;strong&gt;I've been using it to edit videos a bunch&lt;/strong&gt;, and I'm like, okay, it has to meet the very exacting demands of our brand team in a couple of hours or we just can't do it. &lt;strong&gt;That's how I'm trying to shift with Fable: the best work we've ever done, faster than we've ever done it before&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="what-piece-of-conventional-software-engineering-no-longer-holds-"&gt;What piece of conventional software engineering no longer holds?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=219s"&gt;3:39&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; What's a piece of conventional software engineering that was true a year ago that you don't think holds anymore in this new world?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; One of the biggest shifts we're seeing in the eng skill set: two years ago it was pretty typical for a product manager to go talk to a bunch of customers, align over the course of six months with cross-functional teams on some PRD, and write a thorough spec on exactly how we'll implement this before the first line of code gets written. Now things are completely turned the opposite way. For a lot of engineers, the push I would give to folks in the room is to &lt;strong&gt;develop more of your business sense and product sense on what it is we should build&lt;/strong&gt;, because the timeline between having an idea and building it is so much shorter — it's down from six to twelve months to maybe even a week. That means all of us need to have better taste on what is worth building, what will actually inflect the businesses we're working on. So it's &lt;strong&gt;an increase in value on product taste and business sense&lt;/strong&gt;, and a bit lower on execution in most product domains. Of course, for infra there's still a very heavy emphasis on making sure all the details are right.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; For me, it's that &lt;strong&gt;rewrites are now good&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; The worst thing you could do is now actually fine!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; Exactly. All the Mythical Man-Month stuff — never rewrite — I'm pro-rewriting now. If you have a good test suite — and &lt;strong&gt;I think the rewrite actually forces you to make sure you have a good test suite&lt;/strong&gt; — but I think what people undercount is that &lt;strong&gt;a codebase is a spec, and maybe it's the only copy of the spec that you have&lt;/strong&gt;, because no one knows every branching part of the codebase. You can take this as an artifact and distill it or create other versions of it. We &lt;a href="https://bun.com/blog/bun-in-rust"&gt;rewrote Bun in Rust&lt;/a&gt; and it works great — it's live for me right now.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; You're not shipping Claude Code on Bun-in-Rust yet, right?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; Internally we have.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;(Actually it looks like Anthropic started shipping Claude Code on Bun-in-Rust to everyone &lt;a href="https://simonwillison.net/2026/Jul/19/claude-code-in-bun-in-rust/"&gt;on June 17th&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;
&lt;h4 id="what-kind-of-things-are-non-engineers-doing-with-claude-tag-"&gt;What kind of things are non-engineers doing with Claude Tag?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=396s"&gt;6:36&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; The other big launch recently was &lt;strong&gt;&lt;a href="https://www.anthropic.com/news/introducing-claude-tag"&gt;Claude Tag&lt;/a&gt;&lt;/strong&gt; — that's what, a week old now, at least for the rest of us. I understand it's being used at Anthropic by non-engineers a great deal. &lt;strong&gt;What kind of things are non-engineers doing with Claude Tag?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Claude Tag is a Claude that lives in your team's collaboration tools. We launched it last week within Slack. &lt;strong&gt;The thing that's different about Claude Tag is it's multiplayer by default&lt;/strong&gt;. Once you add Claude Tag to a Slack channel, you can chime in, your teammates can chime in, and you can collaborate together on the PR. The other big difference is that it's proactive instead of reactive. You can tell Claude Tag, "Hey, monitor every bug report in this channel, put up a PR to fix it, and tag the engineer who last touched this part of the codebase," and it'll do it for the lifetime of the channel without you having to manually tag it in. And the third big shift is that &lt;strong&gt;we've &lt;a href="https://claude.com/docs/claude-tag/users/memory"&gt;added team memory&lt;/a&gt; into this&lt;/strong&gt;. If you tell Claude Tag your preferences in the channel, it'll remember them for every future post. If you always want it to debug outages but you don't want it to debug warnings, just tell it that in natural language in the channel and it'll remember it for you and everyone else on your team.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Internally, we see Claude Tag as the evolution of Claude Code.&lt;/strong&gt; We see this as a large shift in how we work internally. &lt;strong&gt;Claude Tag currently lands 65% of our product eng PRs.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; For all of Anthropic, or just for Claude Code?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; This is just for our product engineering team — &lt;strong&gt;our internal version of Claude Tag lands 65% of our product PRs right now&lt;/strong&gt;. And this is a huge shift; this is more than 50% of our PRs. The way we see people split work between Claude Code and Claude Tag is: Claude Code is still the best place for your most complex tasks, when you're interactively iterating with the agent. &lt;strong&gt;But Claude Tag is great for having it work proactively on your behalf&lt;/strong&gt;, so you no longer need to manually kick off Claude Code for all the bug reports that come up for features you're working on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; And for non-coding cases: for example, before this talk we asked Claude Tag, "Hey, when is Fable releasing?" We wanted to make sure we'd line it up with the announcement. Claude Tag would search our Slack and look at who's been saying what. &lt;strong&gt;As a search engine for your company, it's really valuable.&lt;/strong&gt; It has all the context for your product, so you can ask it metrics-related questions — often when you're making decisions you want them informed by what the metrics say, so you hook it up to your event store. I've seen our marketing team do things like, "Hey, tell me about this feature." They're not programmers, but Claude is a programmer — it can clone the codebase and say, "This is the feature, this is what it looks like, &lt;strong&gt;this is a recording of me using the feature&lt;/strong&gt;." It enables a whole wide variety of things, and I think we're still early in figuring that out.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="claude-tag-as-the-team-collaborative-layer"&gt;Claude Tag as the team collaborative layer&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=606s"&gt;10:06&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; One of the problems I've had with coding agents is that I get how to use them as an individual, but I'm not really clear on how to use them in a team environment. &lt;strong&gt;It sounds like Claude Tag is your current answer to that team collaborative layer for this stuff.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Exactly. And a large percentage of our sessions are actually multiplayer right now. Maybe I say, "Hey, I think we should implement this new feature in Cowork," and I'll tag in Claude Tag to do a first pass at it. Then I'll tell Claude Tag, "Share a recording of your final implementation," and I'll tag in design to take a look. They'll nudge it, then pass it on to eng to take it to the finish line and get it out to prod. It's been this very fluid experience. &lt;strong&gt;We're still trying to iron out what the social dynamics are for steering the same session&lt;/strong&gt;, but we've found that people just observe how others use it and follow those social norms — it's been pretty intuitive for us to integrate Claude Tag into our teams.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; It's great for teaching people, and also for reducing slop, because &lt;strong&gt;the fact that everyone is seeing you use Claude together sort of levels up how you use Claude as well&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This reminded me of how Midjourney solved the challenge of teaching people advanced image prompting by enforcing prompting in public in their Discord channels.&lt;/p&gt;
&lt;h4 id="how-do-you-decide-which-features-are-worth-building-when-building-is-so-much-cheaper-"&gt;How do you decide which features are worth building when building is so much cheaper?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=701s"&gt;11:41&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Something I've found really hard myself is knowing when a feature is worth shipping now that the cost of actually building features has dropped so much.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; How do you deal with the hardest problem in all of engineering — prioritization? &lt;strong&gt;How do you decide which features are worth building and shipping when building a feature is so much more inexpensive now?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; This is the hard thing. There are a few ways we approach it. One is we dogfood our products every single day. Whenever there's something we want to be able to do in our products that we're not able to, instead of finding a different solution we fix our product so it can support that case. &lt;strong&gt;We have a very heavy dogfooding culture internally.&lt;/strong&gt; Before we share our products with everyone in the world, we share them with everyone within Anthropic, and with some early customers who give us very honest feedback about it — the more brutal the better — and we iterate until people love it. &lt;strong&gt;We have an internal bar for the number of active users and the amount of retention a feature has to have before we share it with the world.&lt;/strong&gt; Because this bar is very clear, every engineer knows what they're trying to hit. I think this also levels up our polish, because if the feature isn't polished, people will churn — and then we shouldn't ship that feature.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Using internal user-retention to decide if a feature should ship makes a whole lot of sense to me.&lt;/p&gt;
&lt;h4 id="do-you-have-an-example-of-a-feature-which-surprised-you-"&gt;Do you have an example of a feature which surprised you?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=774s"&gt;12:54&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; &lt;strong&gt;Do you have an example of a feature which surprised you?&lt;/strong&gt; You rolled it out and the engagement was off the charts — something unlikely to be shipped that turned into a real product thing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; I do have one. &lt;strong&gt;A lot of folks on our team love &lt;a href="https://code.claude.com/docs/en/remote-control"&gt;remote control&lt;/a&gt;.&lt;/strong&gt; Remote control lets you use your mobile device, or Claude in the web browser, to connect to a local Claude Code session running in your CLI. I never have this need, because I just kick off the task directly on mobile and it runs in a cloud session without using my local environment — I think because I'm doing very easy coding tasks. It was something I didn't totally understand; I was like, hey, people should just set up remote dev environments. But in practice, once we rolled out remote control, so many people I talk to told me that what they do every night is plug their laptop into a power charger, open a bunch of remote control sessions, lock the screen, &lt;strong&gt;and then use their mobile phone from their couch to control Claude Code&lt;/strong&gt;. So this has become a flow we're now leaning into that I didn't originally get — but now I do.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="does-a-human-review-every-line-of-production-code-in-claude-code-"&gt;Does a human review every line of production code in Claude Code?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=860s"&gt;14:20&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;One of the over-arching themes of the conference was review: how much attention to people spend to reviewing code written for them by coding agents. I was very keen to hear the Claude Code team's take on this!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; How does code review work? &lt;strong&gt;Does a human being review every line of production code that makes it into Claude Code?&lt;/strong&gt; And if not, what are you doing — how do you keep the quality up?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; It varies on the task a lot. &lt;strong&gt;For important areas we have code owners.&lt;/strong&gt; The system prompt is an example where we have a code owner — you really need to get their approval.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; So the code owner is directly responsible for the quality of that area of the code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; That's right.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; And they need to approve any PR that touches it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; We have &lt;a href="https://code.claude.com/docs/en/github-actions"&gt;our code review GitHub bot&lt;/a&gt; review everything — that goes on every PR, and often it's doing the bulk of the review. Something I've seen on the team is that &lt;strong&gt;for more complex PRs you might make an artifact to explain the PR&lt;/strong&gt; so that other people can then review. And we invest a lot into verification, CI/CD, things like that, to make sure that any time anything fails we have a test. We have a really robust environment where Claude can control Claude Code and test it. So there's a multi-pronged approach to code review.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; In general, &lt;strong&gt;we are trying to move to a world where humans don't need to be in the loop&lt;/strong&gt;. For the most critical changes to the core of Claude Code, and the cores of other products, there is always a code owner and they do manually review all the changes. But increasingly, &lt;strong&gt;for the changes at the outer layers, we actually have Claude code review fully review those&lt;/strong&gt;. That sounds pretty scary, but we've had a six-plus-month-long process to get here, and &lt;strong&gt;there are baby steps that you take to build up trust with code review&lt;/strong&gt;. In the beginning we had human review for everything, and then increasingly we would say, &lt;strong&gt;okay, for code changes that touch these files, code review is catching 100% of the issues there — so we actually don't need a human manually reviewing those&lt;/strong&gt;. And when we have incident review, &lt;strong&gt;we look at the PRs that caused the incident and say, okay, how do we update code review to catch that?&lt;/strong&gt; — and we take those PRs and &lt;strong&gt;add them to an eval set&lt;/strong&gt; to make sure our future changes to code review never regress that metric. Removing humans from the code review loop is a big step forward. It can sound scary, and it's not something you can do overnight, but it is something you can do &lt;strong&gt;through many months of investment in the infrastructure&lt;/strong&gt; to give you the confidence that code review is catching everything you care about.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So the key seems to be constantly iterating on the automated review systems themselves, in order to build trust in them over time.&lt;/p&gt;
&lt;h4 id="how-does-a-new-model-affect-your-intuition-for-what-it-can-and-can-t-do-"&gt;How does a new model affect your intuition for what it can and can't do?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=1040s"&gt;17:20&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We got &lt;em&gt;deep&lt;/em&gt; into evals - another hot topic throughout the wider conference.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; I know that Opus 4.8, if I ask it to build me a JSON endpoint that runs a SQL query and outputs JSON, is just going to get it right — that's not something I have to review closely. But then a new model comes along and I don't know how to build trust in Fable quickly, that it's not going to mess things up that Opus didn't. &lt;strong&gt;How does the new model affect your intuition for what it can do and what it can't do?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; The main reason we're building up this &lt;strong&gt;eval base over time is so that new models can be a drop-in replacement&lt;/strong&gt;. When we have a new model, we run the whole eval set and make sure that, for example, Fable is strictly better than Opus 4.8 — and that gives us the confidence to drop it in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Are those model evals for Anthropic as a whole, or Claude Code team-specific?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; We have both. We have evals on our team, and we run code review across every repo within Anthropic, so we have evals for that. And for things like auto mode, we not only have evals across every user within Anthropic — we've also commissioned multiple external testers to red team it, to create environments with prompt injections and malicious inputs, &lt;strong&gt;and make sure that auto mode doesn't let any of those pass&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="how-do-you-build-confidence-that-a-system-prompt-tweak-results-in-better-output-"&gt;How do you build confidence that a system prompt tweak results in better output?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=1121s"&gt;18:41&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; I want to know if the system prompt improvement I made actually improved the product — that's the most basic form of product-specific eval, and I still don't have a great feel for how to do that. &lt;strong&gt;Is that something you're doing such that you have complete confidence that a tweak you've made to the system prompt results in better output?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; &lt;strong&gt;We don't have complete confidence, but we do a lot to make sure that we don't regress performance.&lt;/strong&gt; The starting point is a suite of external evals that we trust, and we complement that with an even larger suite of internal evals that we trust. To start, &lt;strong&gt;we mainly optimize for capability&lt;/strong&gt;: given a complete definition of a task and the full codebase, does Claude make the right decisions, fully fix the bugs, and pass all the tests? That's the starting point and the thing we optimize for, because it's most directly what users want. But there are a lot of behaviors that impact how users feel when they work with Claude Code. For example, &lt;strong&gt;people really don't like it when Claude Code says it's time to go to sleep.&lt;/strong&gt; Or people really don't like it when it says, "Hey, I finished two out of five parts — do you want me to continue?" Yes, please continue. &lt;strong&gt;So we're building up a set of behavioral evals to catch these.&lt;/strong&gt; And as we get user feedback — please be loud with us about your user feedback — we rank the priority issues and go down one by one and build evals for each of them. It's not 100% coverage, but it is a priority for us to increase the coverage.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="how-much-interaction-is-there-between-the-claude-code-team-and-the-model-training-teams-"&gt;How much interaction is there between the Claude Code team and the model training teams?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=1221s"&gt;20:21&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; &lt;strong&gt;How much interaction is there between the Claude Code team and the teams at Anthropic who are training the models in the first place?&lt;/strong&gt; Is that quite a close collaboration?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Across Anthropic, we all work quite closely together. We meet often to talk about what we expect the next generation of models to be able to do. Our research team has also been amazing about showing this publicly — we often talk in our blog posts about how &lt;strong&gt;we're targeting ever-increasing longer-horizon work&lt;/strong&gt;, and how we train Claude itself to be honest, harmless, and helpful. We also put a lot of effort into making sure it's aligned with your intent, even if your intent is expressed in a fuzzy way. Of course, try your best to be specific about what you want, so Claude has all the context — but even when you're not specific, we teach Claude to make good assumptions. It's been a productive partnership.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="the-system-prompt-has-been-reduced-by-80-what-have-you-been-able-to-drop-"&gt;The system prompt has been reduced by 80% — what have you been able to drop?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=1284s"&gt;21:24&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;So many useful prompting tips in this section!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Thariq, you &lt;a href="https://www.youtube.com/watch?v=9fubhllmsBU&amp;amp;t=358s"&gt;mentioned this morning&lt;/a&gt; that the &lt;strong&gt;system prompt for Claude Code has been reduced by 80% because of Claude Fable&lt;/strong&gt;. Can you go into a little more detail? &lt;strong&gt;What kind of things have you been able to drop?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; It wasn't just Fable — it was Opus 4.8 as well, and going forward, future models. We have different system prompts for different models now. One of the patterns we saw is that we were over-constraining Claude. The initial, maybe Opus 4-ish models wanted a lot of examples, and &lt;strong&gt;removing examples was extremely helpful&lt;/strong&gt;, because it was just more creative than the examples we gave it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; That's really interesting, because one of the top prompting tips I give people is: give it examples. If that's no longer true, that kind of breaks my prompting model a little bit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; Same here — I was surprised to hear that. I think now it's more about the shape of what you give it — the tools you give to Claude, your system prompt, things like that. The other thing we did is try to give it more context and &lt;strong&gt;fewer "do not do this"&lt;/strong&gt; instructions, because that's a very strong impulse for Claude, and especially if it conflicts with user instructions later on, that can be extremely confusing to Claude — "I've got this skill that says this and the system prompt says this." So we try to &lt;strong&gt;have fewer hard constraints, more context, and fewer instructions overall&lt;/strong&gt;. It's definitely a science — it took a bunch of evals to build.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; In general, when you're prompting these models, you should always think: &lt;strong&gt;are there edge cases to the instruction that I'm giving it?&lt;/strong&gt; When we went back and reviewed all the instructions in the Claude Code system prompt, &lt;strong&gt;we found a few cases where yes, this statement is 90% true, but there's a real 10% of cases where it's not true&lt;/strong&gt;. We didn't want to constrain the model, or confuse it into thinking it should always do this. One good example is verification. Everyone here wants Claude to verify its work, and we had some instructions in the prompt that said: if you make a front-end change, always verify. But there's a limit to it. If it's changing copy from one string to another string, and the user says "just make a quick fix and update the test," maybe you don't want to verify. &lt;strong&gt;So we've adjusted our wording from "always verify, verify, verify" to something like: most of the time when you're doing front-end work you can't fully understand the experience by hitting the backend endpoints, so when you make larger changes to the user experience, please run the app locally.&lt;/strong&gt; And in fact, that instruction probably isn't even good either, because &lt;strong&gt;what is a large change?&lt;/strong&gt; Maybe it should test small changes too. In general, whenever you give a prompt to the model, &lt;strong&gt;you should think about the ways in which it could be misinterpreted by a well-intentioned human&lt;/strong&gt;, in order to better understand how the model might interpret it — and &lt;strong&gt;soften the prompt&lt;/strong&gt; so that it's actually 100% accurate, because you're giving this prompt to the model 100% of the time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; What's fascinating about that is you're &lt;strong&gt;relying on the model's judgment&lt;/strong&gt; — and that's got to be an Opus/Fable-level thing. Models a year ago did not have the level of judgment necessary to decide whether they were going to test a change or not. But that does break down if you're building for a wide range of models and trying to run the cheaper models for cheaper tasks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; We actually have &lt;strong&gt;a different system prompt per model now&lt;/strong&gt;, for this very reason. It's only our most frontier models that have this 80% token decrease — the older models still have the full system prompt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Do you think Fable and Opus are smart enough to prompt Haiku with more details, because they understand that Haiku has less judgment, less taste?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; We haven't been able to eval it — we don't have any hard data to show it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; There's a tough thing with smaller models sometimes, because &lt;strong&gt;sometimes the larger models can be more token-efficient on a hard problem than the smaller models&lt;/strong&gt;. So there's a bit of intuition to build there — sometimes you really just want frontier intelligence almost all the time. The Pareto curve shifts, and it's hard to find.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; A year ago I did not trust a model to write a prompt. Today the good models are very good at prompting — a lot of my prompts are written by models, which feels absurd but works really well. What helped me come to terms with that was thinking about subagents, which are entirely about a Claude model setting up a prompt for another Claude model.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; &lt;strong&gt;Workflows&lt;/strong&gt; are actually a really good example of this, because it's Claude not just prompting a single subagent, but prompting the orchestration of many subagents, and each one of them gets a very detailed prompt. It's almost a level above just spawning a subagent. I've also been using it on my personal machine, &lt;strong&gt;giving it the Gemini API and saying: here, generate images&lt;/strong&gt;. It's way less lazy than I am at prompting an image model. It's just Claude prompting Claude all the way down.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; I think Claude also wrote the prompt for &lt;a href="https://code.claude.com/docs/en/workflows"&gt;the workflow tool&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; I've read that prompt — it's a good prompt. That's actually a frustration I have with Anthropic generally: you &lt;a href="https://platform.claude.com/docs/en/release-notes/system-prompts"&gt;publish the prompts for Claude Chat&lt;/a&gt;, but you don't include the tool prompts and the Claude Code prompts. I still have to run a proxy to intercept them. &lt;strong&gt;I would love it if the Claude Code prompts were deliberately published&lt;/strong&gt; — they're the documentation. They're how you know what the tool can do and how it works.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; I'll write down that feature request. I'll have Claude Tag do it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Interesting to note that OpenAI's &lt;a href="https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.6#favor-leaner-prompts"&gt;prompting best practices for GPT-5.6&lt;/a&gt; includes similar advice for their latest models:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Favor leaner prompts&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Removing repeated instructions and examples and simplifying tool descriptions can improve task performance and token efficiency. In a sample of internal coding-agent eval runs, configurations with leaner system prompts improved evaluation scores by roughly 10–15% while reducing total tokens by 41–66% and cost by 33–67%.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id="what-s-your-bar-for-introducing-a-new-tool-"&gt;What's your bar for introducing a new tool?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=1686s"&gt;28:06&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Claude Code is basically a big bag of tools. &lt;strong&gt;What's your bar for introducing a new tool?&lt;/strong&gt; How do you decide when it's worth doing that additional engineering at that level?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Do you want to take it? You introduced one of the best tools we have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; My career peaked when I introduced the ask user question tool. It's really hard. Especially for some tools — &lt;strong&gt;ask user question is Claude's tool to ask you&lt;/strong&gt; — so it's hard to eval, and sometimes it's more of a user preference thing. Back then we had fewer evals, so it was very dogfooding based — or "ant fooding," our ant version of that. But overall &lt;strong&gt;we've been trying to trend towards fewer tools&lt;/strong&gt;. The last set of tools we introduced was the task tool, I think — and we try to give Claude more general versions to do things.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="what-s-the-latest-evolution-of-your-file-editing-tool-"&gt;What's the latest evolution of your file editing tool?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=1743s"&gt;29:03&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I have a long-running fascination with file editing tools - they were the subject of the &lt;a href="https://aider.chat/docs/leaderboards/edit.html"&gt;old Aider code editing leaderboard&lt;/a&gt;, and I've watched with interest as they've evolved in different coding agents from search-and-replace based to line-number-based to more complicated patterns.&lt;/p&gt;
&lt;p&gt;The Claude API docs describe a &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/text-editor-tool"&gt;text editing tool&lt;/a&gt; that's recommended for building against the API, but Claude Code seems to use slightly different approaches here.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; One of the most interesting tools is the file editing tool — you can have file editing as a tool, or you can tell it to use sed and grep and do things that way. &lt;strong&gt;What's the latest evolution of your file editing tool?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; We still have one, but for example we removed our grep and other search tools — glob tools — in favor of native bash. Like I said in my talk earlier, &lt;strong&gt;the models are kind of more of a biology than a physics&lt;/strong&gt;, and tool design especially is quite hard. I'm not sure if Cat disagrees and thinks there's a science to the eval of it, but I think tool design is more of an art, maybe — or a biology.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; I largely agree, but in general as we introduce more tools, we try to keep the cardinality pretty low and make sure that &lt;strong&gt;every tool we add has a distinct function from every other tool, so that Claude can very easily distinguish when to call each&lt;/strong&gt;. For file edit, the reason we have it is actually because we can render it. We show people when Claude makes a file change, and there's this &lt;strong&gt;nice dedicated UI&lt;/strong&gt; that says: do you approve this edit to this file? &lt;strong&gt;The reason we had a dedicated file edit tool was so that we could deterministically know&lt;/strong&gt; that Claude was making a file change, so we could show people this nice UI. A lot of new users onboarding still really like this experience, so we've kept it around. But for a lot of us who are on auto mode right now — hopefully you're not on YOLO mode — I don't think it actually matters, and we could probably just remove file edit and be totally fine.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="what-s-the-advice-within-anthropic-for-safely-running-claude-code-"&gt;What's the advice within Anthropic for safely running Claude Code?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=1858s"&gt;30:58&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It's the &lt;a href="https://simonwillison.net/tags/prompt-injection/"&gt;prompt injection&lt;/a&gt; question! Who better than Anthropic employees to explain how Anthropic sees the risk of prompt injection attacks causing their Claude Code instances to run amok?&lt;/p&gt;
&lt;p&gt;It turns out they &lt;em&gt;really&lt;/em&gt; trust their &lt;a href="https://code.claude.com/docs/en/auto-mode-config"&gt;auto mode&lt;/a&gt; - and see that as the feature that enabled Claude Tag.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Let's talk about safety and security. I am deeply aware of the risks of prompt injection, and there are so many bad things that can happen if somebody else tells my Claude Code what to do. I still mostly run Claude Code in YOLO mode and feel incredibly guilty about it. &lt;strong&gt;What's the advice within Anthropic for safely running Claude Code?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Why not auto mode?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; I am starting to use auto mode, but I don't understand it enough to get how safe it is. As of maybe three weeks ago, I'm defaulting to auto mode.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Broadly within Anthropic, almost every single person uses auto mode. It is the best way to do long-running work in Claude Code while being safe. &lt;strong&gt;We've done extensive bashing. We have thousands of evals. We've commissioned many red teamers to create adversarial environments in order to trick Claude Code into doing bad actions, and we've mitigated every single issue that they found.&lt;/strong&gt; We're going to publish some evals in the coming weeks, but we've pretty much mitigated every attack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; That is a big claim.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; We'll share the evals for it so folks can assess, but we've been extremely diligent about identifying all the ways in which Claude might mess up and then updating auto mode to counter it. It doesn't catch 100% of things — that would be way too strong a claim. But &lt;strong&gt;for the main categories of risks that we're concerned about, like prompt injection and data exfiltration, the risks are far lower than the average human reviewer&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I am very much looking forward to learning more about their evals and approach to verifying auto mode.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; A little on how auto mode works — it's useful to build this mental model. Whenever Claude is doing a turn, or a bash call, there's &lt;strong&gt;a Sonnet classifier&lt;/strong&gt; that is judging the tool call and also the context of the conversation — your instruction. There are some things around permissions that are dependent on your request: you don't want to give git push permissions all the time, but if you say "push this to GitHub," you want it to do it — and if you say "don't push," you want it to deny it. Auto mode will do that. That particular thing happens to me a lot, where Claude tried to do something because it's very helpful and proactive, and auto mode saw "don't do this" and surfaced it. &lt;strong&gt;So it's good at the dynamic permissions&lt;/strong&gt; that you yourself give inside the prompt, which I think is really important. It also works well with our &lt;a href="https://code.claude.com/docs/en/sandbox-environments#sandboxed-bash-tool"&gt;sandboxing infrastructure&lt;/a&gt;, because sandboxing is one of those things where there are so many different edge cases that it's hard for us to deterministically follow them. &lt;strong&gt;We have a sandbox, and when something needs to escape the sandbox&lt;/strong&gt; — like a network request — auto mode can look at that request and ask: does this make sense? — and allow it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; I hadn't realized auto mode is interacting with the networking sandbox as well.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; It interacts with any permission prompt the user would otherwise see.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; How old is auto mode? As a feature I had access to, it's only a couple of months old, right?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(It was first made available to the public &lt;a href="https://claude.com/blog/auto-mode"&gt;on March 24th&lt;/a&gt;.)&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; We've been using it within Anthropic &lt;strong&gt;since January&lt;/strong&gt;, so we've been hardening it for quite a while. Anthropic is extremely focused on safety and security, and we've been working broadly across our alignment and safeguards teams to enable the rollout internally, build out these evals, and make auto mode even more robust before sharing it with the world.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; This is also the reason Claude Tag is so good — &lt;strong&gt;Claude Tag uses auto mode&lt;/strong&gt;. I've heard a lot of build-versus-buy questions about a Slackbot, and I'm like: please, you probably shouldn't build your own AI Slackbot. There are so many attack vectors. &lt;strong&gt;You have a feedback channel that users can post feedback into, and now your bot is reading it.&lt;/strong&gt; The work we've put in with auto mode — and we have a general &lt;strong&gt;Swiss cheese defense&lt;/strong&gt; for security; we also RL against this stuff — &lt;strong&gt;I think this is really what makes Claude Tag work&lt;/strong&gt;. It works seamlessly with your permissions, and you don't want to be prompt injected in your Slack.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="are-there-more-security-things-in-the-pipeline-beyond-auto-mode-"&gt;Are there more security things in the pipeline beyond auto mode?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=2154s"&gt;35:54&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Are there any more security things in the pipeline that go beyond auto mode?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; I think we're very secure. &lt;strong&gt;With Claude Tag you can provision your own credentials for Claude&lt;/strong&gt;, so it doesn't need to act on your behalf — you can have Claude as an identity, and that also makes it easier to audit and inspect what Claude is doing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Because Claude Tag is influenced by anyone who can talk to it — it's got a much wider pool of people telling it what to do.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; That's right. And of course we have probes as well with Fable, which is a downstream effect of our safety and research work. I think this is the moment where you see Anthropic being an AI safety company really paying off: &lt;strong&gt;we really want Claude to be able to run in an aligned way over long periods of time&lt;/strong&gt;, and &lt;strong&gt;auto mode has to be basically flawless for this to work&lt;/strong&gt; — it's all downstream of our being an AI safety company.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; We also launched trusted devices for the remote control users out there who want to be safer. And for all of our remote environments, we support &lt;strong&gt;credential injection&lt;/strong&gt;. If you want Claude Code to be able to access Datadog, but you don't want Claude Code itself to hold the Datadog credential, you can set up our identity and credential management system &lt;strong&gt;so that the Datadog credentials are only usable by the agent but not accessible by the agent&lt;/strong&gt; — we insert them on the fly when the agent tries to make a Datadog request.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I really like that credential injection pattern, where Claude Code can access an API via a proxy and that proxy both audits the request and injects the relevant API key - so Claude can access authenticated endpoints without having access to the API credentials itself.&lt;/p&gt;
&lt;h4 id="how-has-the-past-year-and-a-half-changed-how-you-think-about-your-own-craft-"&gt;How has the past year and a half changed how you think about your own craft?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=2273s"&gt;37:53&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Thariq &lt;a href="https://www.youtube.com/watch?v=9fubhllmsBU&amp;amp;t=867s"&gt;talked about a sense of grief&lt;/a&gt; brought on by Fable-class models in his keynote in the morning, and we dived further into that as part of our conversation. I've been calling this &lt;a href="https://simonwillison.net/2026/Feb/15/deep-blue/"&gt;Deep Blue&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; &lt;strong&gt;Let's talk a little bit about the human element.&lt;/strong&gt; &lt;strong&gt;A lot of people are feeling a sense of loss now that so much of what they considered to be their role in building software is being subsumed by the models.&lt;/strong&gt; How do you think about that? &lt;strong&gt;How has the past year and a half changed the way you think about your own craft and the value that you add?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; Cat and Boris are such good reminders that you have to be more ambitious. They're always like: we're growing so fast, we have to be on the edge, we have to do the best work we can. That's a constant reminder for me — any time I'm slow on something, I'm like, okay, can I do it faster? Can I be more ambitious here? And oftentimes the answer is Claude, because Claude is getting better as you go — the last time I tried this, it was with the previous model. On your point about loss: I think this is real. &lt;strong&gt;If you're only trying to do the same work you were doing before LLMs, and now it's a prompt, it is, I think, kind of a sad feeling.&lt;/strong&gt; And &lt;strong&gt;the way you offset that is by being more ambitious.&lt;/strong&gt; I think Jared is such a good example — he hand-wrote all of the Zig code in his Oakland apartment in about a year, barely left his house, and had so much fun doing that. Now I see him rewrite all of Bun into Rust and &lt;strong&gt;he's having so much fun doing that&lt;/strong&gt; — it's so much more ambitious, and that's how he offsets it. Generally it's asking &lt;strong&gt;how do I do the bigger thing&lt;/strong&gt; and do more — &lt;strong&gt;I think success is fun&lt;/strong&gt;. It's changing your ambition.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;"The way you offset that is by being more ambitious" neatly captures where I've landed on this issue myself as well.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; And Cat, what does that look like from a product management perspective?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; I feel like the product role just changes every single month. &lt;strong&gt;All the PMs on our team are this mix of engineer, designer, PM&lt;/strong&gt; — most of them actually used to be full-time engineers. For us it really means &lt;strong&gt;plugging in whenever there's any kind of gap&lt;/strong&gt;. If we have an idea and we didn't inspire any engineer to go build it, then we should just build it, put it into a notebook, and inspire people to take it to production. If the designs look a little off, &lt;strong&gt;let's take a page that's similar, do a first-pass design, and tag in someone who's very detail-oriented to fill in the gaps&lt;/strong&gt;. Or if we notice that our team and product adoption is bigger within the company, and more people need to know what's coming down the pipe for Claude Code, Claude Tag, and Cowork — let's automate figuring out our whole launch calendar, &lt;strong&gt;let's automate getting those status updates asynchronously&lt;/strong&gt; so we're not bugging people, and make sure our updates in our internal announce channels are fully detailed and to the point. For us it's very much understanding &lt;strong&gt;what the gap is right now between a great idea and getting something to our customers&lt;/strong&gt;, and &lt;strong&gt;how do we automate it as much as possible&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This reflects something I've noticed: when you can produce code so much faster, time spent blocked awaiting a decision from someone else becomes a much more notable bottleneck. Engineers who can make product decisions can move a whole lot faster, and the cost of getting one of those decisions wrong is much less prohibitive.&lt;/p&gt;
&lt;h4 id="what-s-a-moment-when-claude-has-surprised-you-"&gt;What's a moment when Claude has surprised you?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=2510s"&gt;41:50&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; &lt;strong&gt;What's a moment when Claude has surprised you?&lt;/strong&gt; When the model did something you didn't think it would be able to do?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; I've posted a lot about Claude video editing, but most recently I gave a talk at the ACM Agentic conference, and I asked, "Hey guys, do you have the edited video? I'd love to post it and share it with my comms team." They said, "Oh, it's taking so long." So I asked for the raw files. They sent me the video of me talking on stage, the video of the deck, and the audio file, and said, "Good luck." I gave this to Claude, along with my HTML deck, and said, "&lt;strong&gt;Hey, can you just edit this together?&lt;/strong&gt;" And what it does is honestly incredible — I'm ready to ship it. It transcribes the entire video. It notices that sometimes the video of my deck is a little weird — there's a popup of an auto-update in the middle — and it goes, "&lt;strong&gt;Oh, I probably shouldn't use the video of your deck. What I'm going to do is slice it up, figure out which slide you're on, and use the HTML source instead.&lt;/strong&gt;" So it displays the HTML source. Then it's got video of me, but I'm only taking up a small part of the stage, so &lt;strong&gt;it's cropping dynamically to where I am on the stage&lt;/strong&gt; — and I'm pacing, so it's tracking me as I pace. And it's transcribing what I'm saying.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; This was Fable, right?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; This was Fable, yeah. It was a good prompt, but it was a one-shot prompt. Then I asked it to add some interesting animations and graphics, and I was just blown away. &lt;strong&gt;It does ffmpeg, it does Remotion.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's Thariq's video &lt;a href="https://twitter.com/trq212/status/2064826394589442448"&gt;on how he used Fable to edit Fable's own launch video&lt;/a&gt;, and here's &lt;a href="https://twitter.com/ClaudeDevs/status/2064399512664526853"&gt;that launch video&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="what-can-t-it-do-yet-"&gt;What can't it do yet?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=2616s"&gt;43:36&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I'm embarrased to admit that I've been finding it quite hard to come up with tasks that frontier models like Fable 5 and GPT-5.6 are unable to accomplish.&lt;/p&gt;
&lt;p&gt;Cat still doesn't rate its UX design skills:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; What can't it do? What are the things where you're still disappointed — where you're waiting for Claude Fable 6 to figure it out for you?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; I want it to have better design and UX taste. It's now at the point where if I write out a prompt with a detailed spec of how I want a feature to behave, it will usually behave that way. But the paddings might be off, or the interface just isn't delightful yet. It leans on existing best practices for how apps are designed, but &lt;strong&gt;for frontier AI products, there are so many new interaction experiences that we have yet to design&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; There's an Opus aesthetic — you can look at something and go, "Yeah, that was designed by Opus." It'd be good if we could move beyond that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Yeah. I'm very excited for future models to hopefully be &lt;strong&gt;interaction design thought partners&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; What can't it do? I would love to see it interact more with the real world. Can it solve science? Can it orchestrate the experiments? There's some amount of coding that goes into that, but there's also this other taste of the broader world that it needs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="which-parts-of-anthropic-s-culture-should-other-companies-steal-"&gt;Which parts of Anthropic's culture should other companies steal?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=2711s"&gt;45:11&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I figured this would make a great closing question:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; &lt;strong&gt;Which parts of Anthropic's company culture do you think uniquely help Anthropic be productive with these tools, that other companies should steal?&lt;/strong&gt; What are the cultural hacks people should be adopting from you?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; I'll share one for Claude Tag. &lt;strong&gt;Claude Tag works best when you have it in a public channel, and when most of your channels are public.&lt;/strong&gt; Claude Tag is able to search across all public channels to get as much context as possible to give you the highest-accuracy answer — and &lt;strong&gt;it's only able to do this if it has access to everything&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; I mentioned this in my keynote, but it's so important to me I want to re-emphasize it. The co-founders &lt;strong&gt;say we don't negotiate against ourselves&lt;/strong&gt;, and I think this is really important. &lt;strong&gt;You can imagine trade-offs in your head and talk yourself out of doing something ambitious — or you can just try to do the ambitious thing.&lt;/strong&gt; We're so often asking: what if we just did it? Is this a real trade-off or not? And if so, why — where's the proof that it's a real trade-off, and not just something that sounds reasonable? &lt;strong&gt;Make the trade-offs show themselves to you. Be as ambitious as you can.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="what-s-your-favorite-absurd-thing-you-ve-built-with-claude-just-because-you-could-"&gt;What's your favorite absurd thing you've built with Claude, just because you could?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=2806s"&gt;46:46&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I couldn't resist throwing in this one as well.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; &lt;strong&gt;What's one of your favorite absurd things that you've built with Claude, just because you could build it?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; I'm working on &lt;strong&gt;a 2D Street Fighter fighting game with me as a character&lt;/strong&gt; — and my friends as well. It uses Claude Code to prompt Gemini — and honestly the Seedance model is pretty good — to make video animations. It works great; it's so good at prompting, and it can verify the frames to check whether an animation was good.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; Is this Street Fighter 2-level 2D sprites you're generating?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; Yeah, exactly — 2D sprites. The animation looks amazing. And it can also figure out hitboxes — it can be like, "Oh, your fist is here, I'll draw the JSON hitbox." It's incredible.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Mine is much more simple. I'm a big rock climber and a lot of my friends climb, so we have this little app we built with Claude Code where we log all the projects we're working on. We also go outdoors together a lot, so we have Claude do all this research with workflows. Workflows is amazing — we brand it as a coding tool, but it's amazing for doing deep research for travel. I also plan our team offsites, and it's good at finding venues that can fit all of us. I use workflows to research all the climbing destinations we might want to go to, and what has direct flights from where all of us are located. It goes to Mountain Project and finds all the climbs at our grade level. It finds the Airbnb. And I don't like hiking, so I care a lot about it having a very short approach — &lt;strong&gt;very short walking distance from where the car parks to where the rock actually is&lt;/strong&gt; — and it filters for this. With existing apps I have to manually click through Mountain Project, but with this I just put in all of our preferences and it's a custom app for us.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simon:&lt;/strong&gt; So you're basically vibe coding Jira for mountain climbing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; Exactly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="audience-any-plans-for-eval-building-tools-and-agent-observability-"&gt;Audience: Any plans for eval-building tools and agent observability?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=2963s"&gt;49:23&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We had a few minutes at the end for questions from the audience.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Audience:&lt;/strong&gt; Do you have any near-term plans to build more eval tools for us to build eval datasets, and more observability tools to monitor the performance of agents and workflows?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cat:&lt;/strong&gt; We've considered building eval tools, but I think the limiting factor actually tends to be that &lt;strong&gt;it takes a long time for customers to build really high-quality evals&lt;/strong&gt;. So I think the tooling is less of the constraint, and more the skill set of how you build a great eval. That's an area where we're excited to both invest internally and hopefully share some best practices externally.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="audience-how-is-memory-designed-today-and-would-you-move-from-files-to-a-data-store-"&gt;Audience: How is memory designed today — and would you move from files to a data store?&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=uU5Gv2h8-9g&amp;amp;t=3008s"&gt;50:08&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Audience (Sai):&lt;/strong&gt; I'm interested in the memory and the multiplayer. &lt;strong&gt;How is memory being designed today?&lt;/strong&gt; I assume it's around files. And second, have you thought about an orthogonal direction where you &lt;strong&gt;would actually need a data store for these memories, instead of files, to scale it better?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Thariq:&lt;/strong&gt; Right now for Claude Tag the memory is channel-specific. Every Claude in that channel has a shared memory, and the instances have a session — but the session can contribute back to main memory. We do a lot of memory research, and it can be kind of unintuitive what the right way to do memory is. We're always running memory experiments. &lt;strong&gt;How it works right now in Claude Tag is a markdown file per channel.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="ai"/><category term="prompt-engineering"/><category term="generative-ai"/><category term="llms"/><category term="anthropic"/><category term="annotated-talks"/><category term="coding-agents"/><category term="claude-code"/><category term="thariq-shihipar"/><category term="cat-wu"/></entry><entry><title>Kimi K3, and what we can still learn from the pelican benchmark</title><link href="https://simonwillison.net/2026/Jul/16/kimi-k3/#atom-entries" rel="alternate"/><published>2026-07-16T20:19:30+00:00</published><updated>2026-07-16T20:19:30+00:00</updated><id>https://simonwillison.net/2026/Jul/16/kimi-k3/#atom-entries</id><summary type="html">&lt;p&gt;Chinese AI lab Moonshot AI &lt;a href="https://www.kimi.com/blog/kimi-k3"&gt;announced Kimi K3&lt;/a&gt; this morning, describing it as their "most capable model to date, with 2.8 trillion parameters". It's currently available via their website and API, but an open weight release is promised "by July 27, 2026".&lt;/p&gt;
&lt;p&gt;Moonshot are calling this the first "open 3T-class model" (I guess they're rounding 2.8 trillion up to 3 trillion), taking the crown from &lt;a href="https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro"&gt;DeepSeek's 1.6T v4 Pro&lt;/a&gt;. Their &lt;a href="https://www.kimi.com/blog/kimi-k3#full-benchmark-table"&gt;self-reported benchmarks&lt;/a&gt; have K3 mostly beating Claude Opus 4.8 max and GPT-5.5 high, while losing out to Claude Fable 5 and GPT-5.6 Sol.&lt;/p&gt;
&lt;p&gt;A few highlights from the &lt;a href="https://twitter.com/ArtificialAnlys/status/2077832874183860404"&gt;Artificial Analysis report&lt;/a&gt; on the model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;"On our private long-horizon knowledge work evaluation, Kimi K3 reaches an overall Elo of 1547, +732 points from Kimi K2.6 and behind only Claude Fable 5."&lt;/li&gt;
&lt;li&gt;"Cost per task ($0.94) is similar to GPT-5.6 Sol ($1.04), ~1/2 the price of Opus 4.8 ($1.80) and higher than open weights peers"&lt;/li&gt;
&lt;li&gt;"Kimi K3’s token usage on the Artificial Analysis Intelligence Index decreased significantly, using 21% fewer output tokens than K2.6."&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The model is also now the &lt;a href="https://twitter.com/arena/status/2077824029126504525"&gt;leading model on Arena.ai's Frontend Code arena&lt;/a&gt;, surpassing even Claude Fable 5.&lt;/p&gt;
&lt;p&gt;The new model is notable for the pricing: $3/million input tokens and $15/million output tokens, putting it at the same level as Anthropic's Claude Sonnet series and making it the most expensive model released by a Chinese AI lab to date. This is a significant increase on their earlier models &lt;a href="https://platform.kimi.ai/docs/pricing/chat-k26"&gt;such as Kimi K2.6&lt;/a&gt; at $0.95/$4. 2.8 trillion parameters is also more than twice the size of that 1T model.&lt;/p&gt;
&lt;h4 id="but-how-does-it-pelican-"&gt;But how does it pelican?&lt;/h4&gt;
&lt;p&gt;I used OpenRouter (to avoid signing up for a Moonshot API key) with the &lt;a href="https://github.com/simonw/llm-openrouter"&gt;llm-openrouter plugin&lt;/a&gt; to generate an SVG of a pelican riding a bicycle:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -m openrouter/moonshotai/kimi-k3 'Generate an SVG of a pelican riding a bicycle'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/66a2699eb1594258904c7b5102840dd6"&gt;the transcript&lt;/a&gt;. It looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/kimi-3-pelican.jpg" alt="See description below" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;That pelican took 95 input tokens and 16,658 output tokens (13,241 were reasoning tokens), for a total cost of &lt;a href="https://www.llm-prices.com/#it=95&amp;amp;ot=16658&amp;amp;ic=3&amp;amp;oc=15"&gt;25 cents&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;Since K3 accepts image input I ran it against that rendered SVG above (with my &lt;a href="https://simonwillison.net/guides/agentic-engineering-patterns/prompts/#alt-text"&gt;alt text prompt&lt;/a&gt;) and &lt;a href="https://gist.github.com/simonw/665dbf840701b421745f2cb891acdfd6"&gt;got back&lt;/a&gt; (for &lt;a href="https://www.llm-prices.com/#it=822&amp;amp;ot=243&amp;amp;ic=3&amp;amp;oc=15"&gt;0.6 cents&lt;/a&gt;):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Cartoon illustration of a white pelican wearing a red scarf, riding a red bicycle along a gray road with white dashed lines; the pelican has a large orange beak and webbed orange feet pedaling, with white motion lines behind it; the background shows a light blue sky with white clouds, a yellow sun, two small black birds in flight, and green grass with tiny white flowers in the foreground&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="what-can-we-learn-from-the-pelican-"&gt;What can we learn from the pelican?&lt;/h4&gt;
&lt;p&gt;My &lt;a href="https://simonwillison.net/tags/pelican-riding-a-bicycle/"&gt;Generate an SVG of a pelican riding a bicycle&lt;/a&gt; test is 21 months old now. It was never a particularly great benchmark. It started out as a joke on how absurdly difficult it is to compare these models, but then for the first year it turned out to have a &lt;a href="https://simonwillison.net/2025/Jun/6/six-months-in-llms/"&gt;surprising correlation&lt;/a&gt; to how good the models actually were.&lt;/p&gt;
&lt;p&gt;That connection has been mostly severed now. The &lt;a href="https://simonwillison.net/2026/Jul/9/gpt-5-6/"&gt;GPT-5.6&lt;/a&gt; and &lt;a href="https://simonwillison.net/2026/Jun/9/claude-fable-5/"&gt;Claude Fable 5&lt;/a&gt;  pelicans are outclassed &lt;a href="https://simonwillison.net/2026/Jun/17/glm-52/"&gt;by GLM-5.2&lt;/a&gt;, and much as I love GLM I don't think that's a Fable-class model.&lt;/p&gt;
&lt;p&gt;(I'm still not convinced that labs are &lt;a href="https://simonwillison.net/2025/Nov/13/training-for-pelicans-riding-bicycles/"&gt;training for the benchmark&lt;/a&gt; - if they were, I'd expect much better results. There's a chance that Gemini has optimized for &lt;a href="https://simonwillison.net/2026/Feb/19/gemini-31-pro/#jeff-dean"&gt;any combination of an animal on a vehicle&lt;/a&gt; though!)&lt;/p&gt;
&lt;p&gt;The biggest limitation of the pelican is that it doesn't touch at all on the thing that matters most for today's model: agentic tool calling and the ability to operate tools reliably as conversations grow in length.&lt;/p&gt;
&lt;p&gt;So don't go using pelicans to compare models!&lt;/p&gt;

&lt;p&gt;All of that said, I still get a decent amount of value out of running the benchmark myself.&lt;/p&gt;
&lt;p&gt;Firstly, it's a forcing function for actually trying the model. If I show you a pelican, that means I've managed to run a prompt through it. If the model has an official API I'll use that, if it's open weight (and small enough to fit a 128GB M5 MacBook Pro) I'll try running it on my own machine, usually via &lt;a href="https://github.com/ggml-org/llama.cpp"&gt;llama.cpp&lt;/a&gt; or &lt;a href="https://lmstudio.ai"&gt;LM Studio&lt;/a&gt; or &lt;a href="https://ollama.com"&gt;Ollama&lt;/a&gt;. I'll frequently use &lt;a href="https://openrouter.ai"&gt;OpenRouter&lt;/a&gt; since that usually provides a proxy to an official API without me needing a new API key.&lt;/p&gt;
&lt;p&gt;Most of my pelicans are generated using &lt;a href="https://llm.datasette.io/"&gt;my LLM CLI tool&lt;/a&gt;, which helps encourage me to ensure the latest models are supported by that (via one of its plugins).&lt;/p&gt;
&lt;p&gt;More importantly though, even the act of a single prompt to "Generate an SVG of a pelican riding a bicycle" can reveal interesting model characteristics.&lt;/p&gt;
&lt;p&gt;Consider &lt;a href="https://gist.github.com/simonw/66a2699eb1594258904c7b5102840dd6"&gt;the result&lt;/a&gt; for Kimi K3 today. Running those simple prompts helped emphasize several points about the model.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It only has one reasoning effort right now, "max" - and it shows. The model consumed 13,241 reasoning tokens to output 3,417 tokens of response. This is expensive - the pelican cost 25 cents!&lt;/li&gt;
&lt;li&gt;How does the prompt "Generate an SVG of a pelican riding a bicycle" add up to 95 input tokens?  OpenAI's &lt;a href="https://platform.openai.com/tokenizer"&gt;tokenizer&lt;/a&gt;  counts 10, &lt;a href="https://tools.simonwillison.net/claude-token-counter"&gt;Anthropic's&lt;/a&gt; counts 10 for Opus 4.6, 30 for Opus 4.7 and 25 for Sonnet 5/Fable 5. Prompting "hi" &lt;a href="https://news.ycombinator.com/item?id=48935342#48936461"&gt;to Kimi K3&lt;/a&gt; counted 86 tokens, suggesting there may be an 85 token hidden system prompt. It &lt;a href="https://news.ycombinator.com/item?id=48935342#48936515"&gt;refused to leak it&lt;/a&gt; though.&lt;/li&gt;
&lt;li&gt;Vision works well: the alt text it generated is very good.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;K3 currently only has one thinking effort level, but I've been deriving quite a bit of value recently from running the same pelican prompt through different effort levels to get a quick idea for what impact those have. Here's my matrix &lt;a href="https://static.simonwillison.net/static/2026/gpt-5.6-pelicans.html"&gt;for the GPT-5.6 model family&lt;/a&gt;, for example.&lt;/p&gt;
&lt;p&gt;Really though the main things I gain from the pelican test are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It's a "hello world" exercise for prompting a model&lt;/li&gt;
&lt;li&gt;A rough cost and reasoning estimate for a simple task&lt;/li&gt;
&lt;li&gt;Confirmation that the model can output valid SVG and has a basic idea of geometry and spatial awareness. This is a much bigger deal for the smaller models that run on my laptop.&lt;/li&gt;
&lt;li&gt;It's still interesting to compare pelicans between releases in the same model family. K3's pelican is a notable improvement from &lt;a href="https://simonwillison.net/2026/Jan/27/kimi-k25/"&gt;Kimi 2.5&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;It's something I can share that demonstrates I've tried it. Plus a comment with a pelican in it is kind of a tradition on Hacker News at this point, any time I'm late I get comments asking where it is!&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="llm-pricing"/><category term="pelican-riding-a-bicycle"/><category term="llm-release"/><category term="ai-in-china"/><category term="artificial-analysis"/><category term="moonshot"/><category term="kimi"/></entry><entry><title>The new GPT-5.6 family: Luna, Terra, Sol</title><link href="https://simonwillison.net/2026/Jul/9/gpt-5-6/#atom-entries" rel="alternate"/><published>2026-07-09T19:46:38+00:00</published><updated>2026-07-09T19:46:38+00:00</updated><id>https://simonwillison.net/2026/Jul/9/gpt-5-6/#atom-entries</id><summary type="html">&lt;p&gt;OpenAI's latest flagship model &lt;a href="https://openai.com/index/gpt-5-6/"&gt;hit general availability this morning&lt;/a&gt;, and comes in three sizes: Luna, Terra, and Sol (from smallest to largest).&lt;/p&gt;
&lt;p&gt;The new models are priced per 1M input/output tokens as Luna $1/$6, Terra $2.50/$15, Sol $5/$30. For comparison, the Claude Opus series are $5/$25 and the Claude Fable 5 is $10/$50, but price-per-million tokens doesn't tell us much now that the number of reasoning tokens can differ so much between models for the same task.&lt;/p&gt;

&lt;p&gt;All three models have a February 16th 2026 knowledge cutoff, a million token context window, and 128,000 maximum output tokens.&lt;/p&gt;

&lt;p&gt;OpenAI's biggest benchmark claim concerns long-running agentic performance, with one benchmark showing all three models outperforming Claude Fable 5:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We trained GPT-5.6 to get more useful work from every token. On &lt;a href="https://agents-last-exam.org/"&gt;Agents’ Last Exam&lt;/a&gt;, an evaluation of long-running professional workflows across 55 fields, GPT-5.6 Sol sets a new high of 53.6, eclipsing Claude Fable 5 (adaptive reasoning) by 13.1 points. Even at medium reasoning, it beats Fable 5 by 11.4 points at roughly one-quarter the estimated cost. That efficiency extends to smaller models, which are essential to making intelligence more abundant and affordable: GPT-5.6 Terra and GPT-5.6 Luna outperform Fable 5 at around one-sixteenth the cost.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Amusingly, one self-reported benchmark that Fable 5 crushed the GPT-5.6 family on was SWE-Bench Pro, where Fable 5 got 80% compared to GPT-5.6 Sol getting 64.6%. This may help explain why OpenAI chose to publish &lt;a href="https://openai.com/index/separating-signal-from-noise-coding-evaluations/"&gt;this article yesterday&lt;/a&gt; specifically calling out SWE-Bench Pro for problems they found while auditing that benchmark:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;In light of these results, we estimate that ~30% of SWE-bench Pro tasks are broken, and advise that model developers carefully examine results&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I've had some early access to GPT-5.6 Sol - it's definitely very competent, though so far it hasn't struck me as better than Fable at the kind of complex coding tasks I've been using with Anthropic's model.&lt;/p&gt;
&lt;p&gt;As usual, the &lt;a href="https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.6"&gt;model guidance for using GPT-5.6&lt;/a&gt; has the most interesting details. There are a bunch of new API features that I need to explore (and probably add support for in &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt;), including:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/api/docs/guides/tools-programmatic-tool-calling"&gt;Programmatic Tool Calling&lt;/a&gt; allows the models to "compose and run JavaScript that orchestrates tool calls" - which sounds to me like it could help bridge the gap between MCPs and full terminal sessions that can compose CLI utilities in useful ways. Also reminiscent of the &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool#dynamic-filtering"&gt;dynamic filtering&lt;/a&gt; mechanism Anthropic added to their web search tool, which allows code execution against web results as part of a single model turn.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/api/docs/guides/tools-multi-agent"&gt;Multi-agent&lt;/a&gt; lets the model "spin up subagents for parallel, focused work" - the sub-agent pattern now baked into the core API.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints"&gt;Prompt cache breakpoints&lt;/a&gt; brings the Claude model of prompt caching to OpenAI, letting you be explicit about where the cache breakpoints are rather than relying on the API to detect them automatically. Personally I much prefer automatic detection (still supported by OpenAI), but presumably there are optimization cost savings to be had here if you put the work in.&lt;/li&gt;
&lt;li&gt;You can now set &lt;a href="https://developers.openai.com/api/docs/guides/images-vision#choose-an-image-detail-level"&gt;detail: original&lt;/a&gt; on image requests to avoid resizing the image at all before it is processed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here's &lt;a href="https://static.simonwillison.net/static/2026/gpt-5.6-pelicans.html"&gt;a full page with 18 different pelicans&lt;/a&gt; - for reasoning efforts none, low, medium, high, xhigh, and max across the three different models. It also lists their token and calculated costs - the least expensive was gpt-5.6-luna at effort none for 0.71 cents, the most expensive was gpt-5.6-sol at max reasoning level for 48.55 cents.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/gpt-5.6-pelicans.webp" alt="A grid of nine pelicans riding bicycles, of varying quality" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;In further pelican news, if you jump to 17:50 in &lt;a href="https://www.youtube.com/live/Wq45rvPGNHs?t=1070s"&gt;their livestream from this morning&lt;/a&gt; you'll see OpenAI's own demo of 3D pelicans riding a tricycle, a bicycle, a pony, and another pelican!&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/pelican-riding-a-pelican.jpg" alt="Frame from a livestream showing a 3D model of a pelican riding another pelican" style="max-width: 100%;" /&gt;&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="ai"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="llm-tool-use"/><category term="llm-pricing"/><category term="pelican-riding-a-bicycle"/><category term="llm-release"/><category term="gpt-5"/></entry><entry><title>sqlite-utils 4.0, now with database schema migrations</title><link href="https://simonwillison.net/2026/Jul/7/sqlite-utils-4/#atom-entries" 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/#atom-entries</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;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&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>sqlite-utils 4.0rc2, mostly written by Claude Fable (for about $149.25)</title><link href="https://simonwillison.net/2026/Jul/5/sqlite-utils-fable/#atom-entries" rel="alternate"/><published>2026-07-05T01:00:48+00:00</published><updated>2026-07-05T01:00:48+00:00</updated><id>https://simonwillison.net/2026/Jul/5/sqlite-utils-fable/#atom-entries</id><summary type="html">&lt;p&gt;I wrote about the &lt;a href="https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/"&gt;sqlite-utils 4.0rc1&lt;/a&gt; release a couple of weeks ago. Since we only have Claude Fable on our Max subscriptions for a few more days, I decided to see if it could help me get to a 4.0 stable release that I felt truly comfortable about, since I try to keep to &lt;a href="https://semver.org"&gt;SemVer&lt;/a&gt; and like my incompatible major versions to be as rare as possible.&lt;/p&gt;
&lt;p&gt;I started with this prompt, in Claude Code for web on my iPhone:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Final review before shipping a stable 4.0 release - very important to spot any last minute things that would be a breaking change if we fix them later&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/simonw/sqlite-utils/blob/0c369a447eeaf39084f0d14a45b3eeb7eacb631b/fable-review-4.0rc1.md"&gt;that initial report&lt;/a&gt; it created for me. There were some &lt;em&gt;significant&lt;/em&gt; problems that I hadn't myself encountered yet - 5 that Fable categorized as "release blockers". Here's the worst of the bunch:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;delete_where()&lt;/code&gt; never commits and poisons the connection (data loss)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Table.delete_where()&lt;/code&gt; (&lt;code&gt;sqlite_utils/db.py:2948&lt;/code&gt;) runs its DELETE via a bare &lt;code&gt;self.db.execute()&lt;/code&gt; with no &lt;code&gt;atomic()&lt;/code&gt; wrapper — compare &lt;code&gt;Table.delete()&lt;/code&gt; at &lt;code&gt;db.py:2944&lt;/code&gt;, which wraps correctly. The connection is left &lt;code&gt;in_transaction=True&lt;/code&gt;, so every &lt;em&gt;subsequent&lt;/em&gt; &lt;code&gt;atomic()&lt;/code&gt; call takes the savepoint branch (&lt;code&gt;db.py:430-440&lt;/code&gt;) and never commits either.&lt;/p&gt;
&lt;p&gt;Reproduced end-to-end:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt;.&lt;span class="pl-c1"&gt;Database&lt;/span&gt;(&lt;span class="pl-s"&gt;"dw.db"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"t"&lt;/span&gt;].&lt;span class="pl-c1"&gt;insert_all&lt;/span&gt;([{&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s1"&gt;i&lt;/span&gt;} &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;i&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-en"&gt;range&lt;/span&gt;(&lt;span class="pl-c1"&gt;3&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-s"&gt;"t"&lt;/span&gt;].&lt;span class="pl-c1"&gt;delete_where&lt;/span&gt;(&lt;span class="pl-s"&gt;"id = ?"&lt;/span&gt;, [&lt;span class="pl-c1"&gt;0&lt;/span&gt;])   &lt;span class="pl-c"&gt;# conn.in_transaction is now True&lt;/span&gt;
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"t"&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;50&lt;/span&gt;})
&lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"u"&lt;/span&gt;].&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"a"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;})
&lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;close&lt;/span&gt;()
&lt;span class="pl-c"&gt;# Reopen: rows are [0, 1, 2] — the delete, row 50, AND table u are all gone.&lt;/span&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's a really bad bug! Very glad I didn't ship that, although at least it would have been a bug I could fix in a 4.0.1 point release, not a design flaw that would force a 5.0.&lt;/p&gt;
&lt;p&gt;Over the course of 37 prompts, 34 commits and +1,321 -190 code changes over 30 separate files, we worked through the entire set of feedback in turn, making several other design improvements along the way.&lt;/p&gt;
&lt;p&gt;A weird thing about coding agents is that harder tasks like this one actually provide &lt;em&gt;more&lt;/em&gt; opportunity to do other things at the same time, since the agent sometimes needs 10-15 minutes to churn away on a new task. I went out to enjoy the Half Moon Bay 4th of July parade, occasionally checking in and prompting the next step for Fable from my phone.&lt;/p&gt;
&lt;p&gt;Full details &lt;a href="https://github.com/simonw/sqlite-utils/pull/767"&gt;in the PR&lt;/a&gt; and &lt;a href="https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd"&gt;this shared transcript&lt;/a&gt;. I switched to my laptop for the final review, which I conducted through GitHub's PR interface.&lt;/p&gt;
&lt;p&gt;The most significant changes relate to transaction handling, which was the signature new feature in &lt;a href="https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/#new-feature-db-atomic-transactions"&gt;the earlier RC&lt;/a&gt;. The new RC now includes &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#transactions-and-saving-your-changes"&gt;comprehensive documentation&lt;/a&gt; on the new transaction model, the intro to which I'll quote here in full:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Every method in this library that writes to the database - &lt;code&gt;insert()&lt;/code&gt;, &lt;code&gt;upsert()&lt;/code&gt;, &lt;code&gt;update()&lt;/code&gt;, &lt;code&gt;delete()&lt;/code&gt;, &lt;code&gt;delete_where()&lt;/code&gt;, &lt;code&gt;transform()&lt;/code&gt;, &lt;code&gt;create_table()&lt;/code&gt;, &lt;code&gt;create_index()&lt;/code&gt;, &lt;code&gt;enable_fts()&lt;/code&gt; and the rest - runs inside its own transaction and commits it before returning. Your changes are saved to disk as soon as the method call finishes:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Database&lt;/span&gt;(&lt;span class="pl-s"&gt;"data.db"&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;"news"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"headline"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Dog wins award"&lt;/span&gt;})
&lt;span class="pl-c"&gt;# The new row is already saved - no commit() required&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;The same applies to raw SQL executed with &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-transactions-execute"&gt;db.execute()&lt;/a&gt; - a write statement is committed as soon as it has run.&lt;/p&gt;
&lt;p&gt;You never need to call &lt;code&gt;commit()&lt;/code&gt;, and you do not need to close the database to persist your changes. There are exactly two situations where you need to think about transactions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;You want to group several write operations together, so they either all succeed or all fail - use &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-atomic"&gt;db.atomic()&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You are &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-transactions-manual"&gt;managing a transaction yourself&lt;/a&gt; with &lt;code&gt;db.begin()&lt;/code&gt;, in which case nothing is committed until you commit - the library will never commit a transaction you opened.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p&gt;In reviewing Fable's documentation - I find that reviewing the documentation edits first is an &lt;em&gt;excellent&lt;/em&gt; way to build an initial understanding of what has changed - I spotted &lt;a href="https://github.com/simonw/sqlite-utils/blob/6c88067ab76b9597fb1c538c53164632526a2891/docs/python-api.rst?plain=1#L386"&gt;this detail&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;db.atomic()&lt;/code&gt; and the automatic per-method transactions are designed for connections in Python's default transaction handling mode. Connections created with the Python 3.12+ &lt;code&gt;sqlite3.connect(..., autocommit=True)&lt;/code&gt; or &lt;code&gt;autocommit=False&lt;/code&gt; options are not supported, because &lt;code&gt;commit()&lt;/code&gt; and &lt;code&gt;rollback()&lt;/code&gt; behave differently on those connections.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I admit I hadn't thought about how &lt;code&gt;sqlite-utils&lt;/code&gt; would react to the more recent &lt;a href="https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.autocommit"&gt;autocommit setting&lt;/a&gt;, added in Python 3.12. It turns out "behave differently on those connections" equated to almost the entire test suite failing, so I worked with the model to &lt;a href="https://github.com/simonw/sqlite-utils/commit/f7ff3e2027aefb9905ebb2e611e5bbb0a62382c5"&gt;ensure that this difference&lt;/a&gt; would not break how the library works.&lt;/p&gt;
&lt;h4 id="and-a-final-review-by-gpt-5-5"&gt;And a final review by GPT-5.5&lt;/h4&gt;
&lt;p&gt;I used to think that the idea of having one model review the work of another was somewhat absurd - it felt weirdly superstitious. The problem is &lt;em&gt;it really does work&lt;/em&gt; - I've started habitually having Anthropic's best model review OpenAI's work and vice versa, because I've had that turn up interesting results often enough to be valuable.&lt;/p&gt;
&lt;p&gt;I prompted Codex Desktop and GPT-5.5 xhigh with the following:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Review changes since the last RC. Also confirm that the changelog is up-to-date.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Which was enough to turn up two issues worth investigating:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Findings&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[P1] &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/sqlite_utils/db.py#L663"&gt;sqlite_utils/db.py:663&lt;/a&gt; &lt;code&gt;db.query()&lt;/code&gt; now rejects non-row statements only after calling &lt;code&gt;db.execute()&lt;/code&gt;, and &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/sqlite_utils/db.py#L705"&gt;sqlite_utils/db.py:705&lt;/a&gt; auto-commits those writes first. So &lt;code&gt;db.query("update ...")&lt;/code&gt; raises &lt;code&gt;ValueError&lt;/code&gt; but the update is already committed. That is a surprising side effect for a method documented as “can only be used with SQL that returns rows.”&lt;/li&gt;
&lt;li&gt;[P1] &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/sqlite_utils/db.py#L672"&gt;sqlite_utils/db.py:672&lt;/a&gt; &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; through &lt;code&gt;db.query()&lt;/code&gt; only commits after the returned generator is fully exhausted. &lt;code&gt;db.query("insert ... returning ...")&lt;/code&gt; without iteration, or common &lt;code&gt;next(db.query(...))&lt;/code&gt; usage, leaves the transaction open and the write can be rolled back on close. This contradicts &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/docs/changelog.rst?plain=1#L15"&gt;docs/changelog.rst:15&lt;/a&gt; and &lt;a href="https://github.com/simonw/sqlite-utils/blob/04f8971546418962aaf6579d4028c7117d6c3a20/docs/python-api.rst?plain=1#L232"&gt;docs/python-api.rst:232&lt;/a&gt;, which say it takes effect without iteration.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I pasted that into a fresh Fable session, which ran some experiments to confirm the problem:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Both findings were confirmed. &lt;code&gt;db.query()&lt;/code&gt; called &lt;code&gt;self.execute()&lt;/code&gt; first, which auto-commits writes, and only then checked &lt;code&gt;cursor.description&lt;/code&gt; — so &lt;code&gt;db.query("update ...")&lt;/code&gt; committed the update before raising &lt;code&gt;ValueError&lt;/code&gt;. And the &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; commit lived at the end of the returned generator, so it never fired unless you exhausted the iterator — &lt;code&gt;next(db.query(...))&lt;/code&gt; or an un-iterated call left the transaction open, contradicting what the changelog and docs promise.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/simonw/sqlite-utils/pull/768"&gt;the PR&lt;/a&gt; with the fix, and the &lt;a href="https://claude.ai/code/session_012U3iRfJoTZ5vd22cBSF2nJ"&gt;full Claude Code transcript&lt;/a&gt;. Reviewing this code helped me build a better mental model of the edge cases of SQLite transaction semantics!&lt;/p&gt;
&lt;h4 id="for-an-estimated-unsubsidized-cost-of-149-25"&gt;For an estimated (unsubsidized) cost of $149.25&lt;/h4&gt;
&lt;p&gt;I upgraded to the Claude Max $200/month plan (I was previously on $100/month) to increase my Fable allowance for the remaining time until &lt;a href="https://www.anthropic.com/news/redeploying-fable-5"&gt;the July 7th Fablepocalypse&lt;/a&gt;, when even Claude Max subscribers will have to pay full API cost for the model.&lt;/p&gt;
&lt;p&gt;I was curious as to how much this would have cost me if I had been paying those costs directly. At first I thought those numbers weren't available to me since I had run the work remotely using Claude Code for web, and then I realized I could run &lt;a href="https://www.agentsview.io"&gt;AgentsView&lt;/a&gt; inside that existing session to get that cost estimate!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Run "uvx agentsview --help"​ and then use that tool to calculate the cost of this session&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Claude figured out how to use the &lt;code&gt;session list --include-children&lt;/code&gt; command and came out with the following:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transcript&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main session&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$141.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API-surface sweep agent&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$2.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactions/atomic review agent&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$2.39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post-rc1 commits review agent&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$1.72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations review agent&lt;/td&gt;
&lt;td&gt;claude-fable-5&lt;/td&gt;
&lt;td&gt;$1.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt-counting agent&lt;/td&gt;
&lt;td&gt;claude-opus-4-8&lt;/td&gt;
&lt;td&gt;$0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$149.25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I'm very glad I'm on that subscription! I really should have &lt;a href="https://simonwillison.net/2026/Jul/3/judgement/"&gt;followed my own advice&lt;/a&gt; and leaned more heavily into subagents with cheaper models.&lt;/p&gt;
&lt;p&gt;Here's what &lt;a href="https://claude.ai/settings/usage"&gt;claude.ai/settings/usage&lt;/a&gt; is showing me right now:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/fable-plan-usage.webp" alt="Screenshot of a Claude plan usage limits panel: &amp;quot;Plan usage limits Max (20x)&amp;quot;; &amp;quot;Current session&amp;quot; with &amp;quot;Resets in 3 hr 52 min&amp;quot; showing a progress bar at &amp;quot;7% used&amp;quot;; &amp;quot;Weekly limits&amp;quot; heading with a &amp;quot;Learn more about usage limits&amp;quot; link; &amp;quot;All models&amp;quot; with &amp;quot;Resets Wed 12:00 PM&amp;quot; showing a progress bar at &amp;quot;32% used&amp;quot;; &amp;quot;Fable&amp;quot; with &amp;quot;Resets Wed 12:00 PM&amp;quot; showing a progress bar at &amp;quot;63% used&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I have several other major Fable-driven projects on the go right now as well, with the goal of hitting 100% on that Fable bar just in time for the price increase.&lt;/p&gt;
&lt;h4 id="the-full-release-notes-for-sqlite-utils-4-0rc2"&gt;The full release notes for sqlite-utils 4.0rc2&lt;/h4&gt;
&lt;p&gt;Here are &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#rc2-2026-07-04"&gt;the full release notes&lt;/a&gt; for the RC. I had Fable add these to an "Unreleased" section of the changelog as each change landed, reviewing them as it went. This has the neat side effect that &lt;a href="https://github.com/simonw/sqlite-utils/commits/4.0rc2/docs/changelog.rst"&gt;the commit history of the changelog&lt;/a&gt; acts as a concise summary of each of the changes that went into the release.&lt;/p&gt;
&lt;p&gt;In the past I've had a policy of writing release notes by hand, but honestly these are better than I would have created myself. Release notes are a great example of writing that I'm OK to outsource to agents because they need to be boring, predictable and accurate.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Breaking changes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Write statements executed with &lt;code&gt;db.execute()&lt;/code&gt; are now committed automatically, unless a transaction is already open in which case they join it. Previously they opened an implicit transaction that stayed open until something committed it - writes appeared to work when read on the same connection but were silently rolled back when the connection closed. Code that relied on rolling back uncommitted &lt;code&gt;db.execute()&lt;/code&gt; writes should use the new &lt;code&gt;db.begin()&lt;/code&gt; method to open an explicit transaction first. The transaction model is documented in full at &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-transactions"&gt;Transactions and saving your changes&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db.query()&lt;/code&gt; now executes its SQL as soon as it is called, rather than waiting until the returned generator is first iterated. Rows are still fetched lazily during iteration. SQL errors are now raised at the call site, statements such as &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; are executed and committed immediately without needing to iterate over their results, and passing a statement that returns no rows - previously a silent no-op - now raises a &lt;code&gt;ValueError&lt;/code&gt; recommending &lt;code&gt;db.execute()&lt;/code&gt; instead. A statement rejected this way is rolled back before the error is raised, so it has no effect on the database.&lt;/li&gt;
&lt;li&gt;Python API validation errors now raise &lt;code&gt;ValueError&lt;/code&gt; instead of &lt;code&gt;AssertionError&lt;/code&gt;. Previously invalid arguments - such as &lt;code&gt;create_table()&lt;/code&gt; with no columns, &lt;code&gt;transform()&lt;/code&gt; on a table that does not exist, or passing both &lt;code&gt;ignore=True&lt;/code&gt; and &lt;code&gt;replace=True&lt;/code&gt; - were rejected using bare &lt;code&gt;assert&lt;/code&gt; statements, which are silently skipped when Python runs with the &lt;code&gt;-O&lt;/code&gt; flag. Code that caught &lt;code&gt;AssertionError&lt;/code&gt; for these cases should catch &lt;code&gt;ValueError&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;table.upsert()&lt;/code&gt; and &lt;code&gt;table.upsert_all()&lt;/code&gt; now raise &lt;code&gt;PrimaryKeyRequired&lt;/code&gt; if a record is missing a value for any primary key column, or has a value of &lt;code&gt;None&lt;/code&gt; for one. Previously such records - which can never match an existing row - were quietly inserted as brand new rows, or triggered a confusing &lt;code&gt;KeyError&lt;/code&gt; after the insert had already taken place.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db.enable_wal()&lt;/code&gt; and &lt;code&gt;db.disable_wal()&lt;/code&gt; now raise a &lt;code&gt;sqlite_utils.db.TransactionError&lt;/code&gt; if called while a transaction is open. Previously they would silently commit the open transaction as a side effect of changing the journal mode, breaking the rollback guarantee of &lt;code&gt;db.atomic()&lt;/code&gt; and of user-managed transactions.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;View&lt;/code&gt; class no longer has an &lt;code&gt;enable_fts()&lt;/code&gt; method. It existed only to raise &lt;code&gt;NotImplementedError&lt;/code&gt;, since full-text search is not supported for views - calling it now raises &lt;code&gt;AttributeError&lt;/code&gt; instead, and the method no longer appears in the API reference. The &lt;code&gt;sqlite-utils enable-fts&lt;/code&gt; command shows a clean error when pointed at a view.&lt;/li&gt;
&lt;li&gt;The no-op &lt;code&gt;-d/--detect-types&lt;/code&gt; flag has been removed from the &lt;code&gt;insert&lt;/code&gt; and &lt;code&gt;upsert&lt;/code&gt; commands. Type detection has been the default for CSV/TSV data since 4.0a1, so the flag did nothing - invocations using it should simply drop it. &lt;code&gt;--no-detect-types&lt;/code&gt; remains available to disable detection.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Database()&lt;/code&gt; now raises a &lt;code&gt;sqlite_utils.db.TransactionError&lt;/code&gt; if passed a connection created with the Python 3.12+ &lt;code&gt;sqlite3.connect(..., autocommit=True)&lt;/code&gt; or &lt;code&gt;autocommit=False&lt;/code&gt; options. &lt;code&gt;commit()&lt;/code&gt; and &lt;code&gt;rollback()&lt;/code&gt; behave differently on those connections, which previously caused every write made by the library to be silently discarded when the connection closed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything else:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fixed a bug where &lt;code&gt;table.delete_where()&lt;/code&gt;, &lt;code&gt;table.optimize()&lt;/code&gt; and &lt;code&gt;table.rebuild_fts()&lt;/code&gt; did not commit their changes, leaving the connection inside an open transaction. Their work - and any subsequent writes - could then be silently rolled back when the connection was closed. All three now use &lt;code&gt;db.atomic()&lt;/code&gt;, consistent with the other write methods.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;sqlite-utils drop-table&lt;/code&gt; command now refuses to drop a view, and &lt;code&gt;drop-view&lt;/code&gt; refuses to drop a table. Previously each would silently drop the wrong type of object if the name matched. Both now exit with an error suggesting the correct command to use.&lt;/li&gt;
&lt;li&gt;Migrations applied by the new &lt;a href="https://sqlite-utils.datasette.io/en/latest/migrations.html#migrations"&gt;migrations system&lt;/a&gt; now run inside a transaction, together with the record of the migration having been applied. If a migration raises an exception its changes are rolled back and it stays pending, so it can be safely re-applied after the error is fixed. Migrations that cannot run inside a transaction, such as those executing &lt;code&gt;VACUUM&lt;/code&gt;, can opt out using &lt;code&gt;@migrations(transactional=False)&lt;/code&gt; - see &lt;a href="https://sqlite-utils.datasette.io/en/latest/migrations.html#migrations-transactions"&gt;Migrations and transactions&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;table.upsert()&lt;/code&gt; and &lt;code&gt;table.upsert_all()&lt;/code&gt; now detect the primary key or compound primary key of an existing table, so the &lt;code&gt;pk=&lt;/code&gt; argument is no longer required when upserting into a table that already has a primary key.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db.table(table_name).insert({})&lt;/code&gt; can now be used to insert a row consisting entirely of default values into an existing table, using &lt;code&gt;INSERT INTO ... DEFAULT VALUES&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/759"&gt;#759&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Improvements to the &lt;code&gt;sqlite-utils migrate&lt;/code&gt; command: &lt;code&gt;--stop-before&lt;/code&gt; values that do not match any known migration are now an error instead of being silently ignored, &lt;code&gt;--stop-before&lt;/code&gt; now works correctly with migration files that still use the older &lt;code&gt;sqlite_migrate.Migrations&lt;/code&gt; class, and &lt;code&gt;--list&lt;/code&gt; is now a read-only operation that no longer creates the database file or the migrations tracking table. &lt;code&gt;migrations.applied()&lt;/code&gt; now returns migrations in the order they were applied.&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;db.begin()&lt;/code&gt;, &lt;code&gt;db.commit()&lt;/code&gt; and &lt;code&gt;db.rollback()&lt;/code&gt; methods for taking manual control of transactions, as an alternative to the &lt;code&gt;db.atomic()&lt;/code&gt; context manager.&lt;/li&gt;
&lt;li&gt;New documentation: &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-transactions"&gt;Transactions and saving your changes&lt;/a&gt; describes how transactions work and when changes are committed, and a new &lt;a href="https://sqlite-utils.datasette.io/en/latest/upgrading.html#upgrading"&gt;Upgrading&lt;/a&gt; page details the changes needed to move between major versions.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><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="anthropic"/><category term="claude"/><category term="llm-pricing"/><category term="coding-agents"/><category term="claude-code"/><category term="agentic-engineering"/><category term="gpt"/><category term="claude-mythos-fable"/></entry><entry><title>Have your agent record video demos of its work with shot-scraper video</title><link href="https://simonwillison.net/2026/Jun/30/shot-scraper-video/#atom-entries" rel="alternate"/><published>2026-06-30T16:54:26+00:00</published><updated>2026-06-30T16:54:26+00:00</updated><id>https://simonwillison.net/2026/Jun/30/shot-scraper-video/#atom-entries</id><summary type="html">&lt;p&gt;&lt;a href="https://shot-scraper.datasette.io/en/stable/video.html"&gt;shot-scraper video&lt;/a&gt; is a new command introduced in today's &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.10"&gt;shot-scraper 1.10&lt;/a&gt; release which accepts a &lt;code&gt;storyboard.yml&lt;/code&gt; file defining a routine to run against a web application and uses Playwright to record a video of that routine. I've written before about the importance of &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#proving-code-actually-works"&gt;having coding agents produce demos&lt;/a&gt; of their work; this is my latest attempt at enabling them to do that.&lt;/p&gt;
&lt;p&gt;Here's an example video created using &lt;code&gt;shot-scraper video&lt;/code&gt;, exercising a &lt;a href="https://github.com/simonw/datasette/pull/2813"&gt;still in development&lt;/a&gt; feature adding the ability to create new tables in Datasette from pasted CSV, TSV or JSON data:&lt;/p&gt;
&lt;div style="max-width: 100%; margin-bottom: 0.4em"&gt;
    &lt;video controls="controls" preload="none" aria-label="Video demo of the new CSV import for Datasette" poster="https://static.simonwillison.net/static/2026/datasette-bulk-insert-demo.jpg" loop="loop" style="width: 100%; height: auto;" muted="muted"&gt;
        &lt;source src="https://static.simonwillison.net/static/2026/datasette-bulk-insert-demo.mp4" type="video/mp4" /&gt;
    &lt;/video&gt;
&lt;/div&gt;
&lt;p&gt;That video was created by running this command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;shot-scraper video datasette-bulk-insert-storyboard.yml \
  --auth datasette-demo-auth.json --mp4&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(That &lt;code&gt;--auth&lt;/code&gt; JSON file &lt;a href="https://gist.github.com/simonw/287b26aff53fcb72942b19f5b69d7e5c"&gt;contains a cookie&lt;/a&gt;, as &lt;a href="https://shot-scraper.datasette.io/en/stable/authentication.html"&gt;described here&lt;/a&gt; in the documentation.)&lt;/p&gt;
&lt;p&gt;Here's the &lt;code&gt;datasette-bulk-insert-storyboard.yml&lt;/code&gt; file:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-s"&gt;/tmp/datasette-bulk-insert-demo.webm&lt;/span&gt;
&lt;span class="pl-ent"&gt;server&lt;/span&gt;:
  - &lt;span class="pl-s"&gt;uv&lt;/span&gt;
  - &lt;span class="pl-s"&gt;--directory&lt;/span&gt;
  - &lt;span class="pl-s"&gt;/Users/simon/Dropbox/dev/datasette&lt;/span&gt;
  - &lt;span class="pl-s"&gt;run&lt;/span&gt;
  - &lt;span class="pl-s"&gt;datasette&lt;/span&gt;
  - &lt;span class="pl-s"&gt;-p&lt;/span&gt;
  - &lt;span class="pl-c1"&gt;6419&lt;/span&gt;
  - &lt;span class="pl-s"&gt;--root&lt;/span&gt;
  - &lt;span class="pl-s"&gt;--secret&lt;/span&gt;
  - &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  - &lt;span class="pl-s"&gt;/tmp/demo.db&lt;/span&gt;
&lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;http://127.0.0.1:6419/demo/tasks&lt;/span&gt;
&lt;span class="pl-ent"&gt;viewport&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;width&lt;/span&gt;: &lt;span class="pl-c1"&gt;1280&lt;/span&gt;
  &lt;span class="pl-ent"&gt;height&lt;/span&gt;: &lt;span class="pl-c1"&gt;720&lt;/span&gt;
&lt;span class="pl-ent"&gt;cursor&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
&lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;button[data-table-action="insert-row"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-ent"&gt;javascript&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  (() =&amp;gt; {&lt;/span&gt;
&lt;span class="pl-s"&gt;    let clipboardText = "";&lt;/span&gt;
&lt;span class="pl-s"&gt;    Object.defineProperty(navigator, "clipboard", {&lt;/span&gt;
&lt;span class="pl-s"&gt;      configurable: true,&lt;/span&gt;
&lt;span class="pl-s"&gt;      get: () =&amp;gt; ({&lt;/span&gt;
&lt;span class="pl-s"&gt;        writeText: async (text) =&amp;gt; {&lt;/span&gt;
&lt;span class="pl-s"&gt;          clipboardText = String(text);&lt;/span&gt;
&lt;span class="pl-s"&gt;        },&lt;/span&gt;
&lt;span class="pl-s"&gt;        readText: async () =&amp;gt; clipboardText,&lt;/span&gt;
&lt;span class="pl-s"&gt;      }),&lt;/span&gt;
&lt;span class="pl-s"&gt;    });&lt;/span&gt;
&lt;span class="pl-s"&gt;  })();&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;&lt;span class="pl-ent"&gt;scenes&lt;/span&gt;:
  - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Bulk insert existing table rows&lt;/span&gt;
    &lt;span class="pl-ent"&gt;do&lt;/span&gt;:
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;button[data-table-action="insert-row"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#row-edit-dialog[open]&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-bulk-insert&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-bulk-textarea&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-copy-template&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Copied&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;fill&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;into&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-bulk-textarea&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;text&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;            title,owner,status,priority,notes&lt;/span&gt;
&lt;span class="pl-s"&gt;            Prepare release video,Ana,doing,1,Recorded with shot-scraper&lt;/span&gt;
&lt;span class="pl-s"&gt;            Check pasted CSV import,Ben,review,3,Previewed before inserting&lt;/span&gt;
&lt;span class="pl-s"&gt;            Share the branch demo,Chen,queued,2,Bulk insert creates three rows&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-save&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Previewing 3 rows.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.2&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-save&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=3 rows inserted.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.0&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.row-edit-cancel&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Prepare release video&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.0&lt;/span&gt;
  - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Create a table from pasted CSV&lt;/span&gt;
    &lt;span class="pl-ent"&gt;open&lt;/span&gt;: &lt;span class="pl-s"&gt;http://127.0.0.1:6419/demo&lt;/span&gt;
    &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;details.actions-menu-links summary&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-ent"&gt;do&lt;/span&gt;:
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;details.actions-menu-links summary&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;button[data-database-action="create-table"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#table-create-dialog[open]&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;fill&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;into&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-table-name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;text&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;launch_metrics&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-from-data&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-data-textarea&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.5&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;fill&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;into&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-data-textarea&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;text&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;            metric_id,name,score,recorded_on&lt;/span&gt;
&lt;span class="pl-s"&gt;            m001,Activation rate,87.5,2026-06-29&lt;/span&gt;
&lt;span class="pl-s"&gt;            m002,Retention check,72.25,2026-06-30&lt;/span&gt;
&lt;span class="pl-s"&gt;            m003,CSV import health,95,2026-07-01&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;0.8&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-save&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Previewing 3 rows.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.2&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;click&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.table-create-save&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for_url&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;**/demo/launch_metrics&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;wait_for&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;text=Activation rate&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
      - &lt;span class="pl-ent"&gt;pause&lt;/span&gt;: &lt;span class="pl-c1"&gt;1.2&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href="https://shot-scraper.datasette.io/en/stable/video.html"&gt;video command documentation&lt;/a&gt; includes simpler examples, but for the purpose of this post I thought I'd go with something more comprehensive.&lt;/p&gt;
&lt;p&gt;That demo YAML storyboard was constructed entirely by GPT-5.5 xhigh running in Codex Desktop, using the following prompt run inside my &lt;code&gt;~/dev/datasette&lt;/code&gt; checkout of &lt;a href="https://github.com/simonw/datasette/commits/b759ea548606bc9bf9a4bf0e33e2d57ead7e0ab8/"&gt;this branch&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Review the changes on this branch.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cd to ~/dev/shot-scraper and run the command "uv run shot-scraper video --help"&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Now use that new video command to record a video demo of the new features from this branch, including running a "uv run datasette -p 6419 --root --secret 1 /tmp/demo.db" development server so you can record the video against a demo DB that you first create.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now that I've released the feature the prompt could say "&lt;code&gt;run uvx shot-scraper video --help&lt;/code&gt;" instead and it should achieve the same result.&lt;/p&gt;
&lt;p&gt;I really like this pattern where the &lt;code&gt;--help&lt;/code&gt; output for a command provides enough detail that a coding agent can use it - it works kind of like bundling a &lt;code&gt;SKILL.md&lt;/code&gt; file directly inside the tool. I used the same pattern for &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/"&gt;showboat and rodney&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="how-i-built-this"&gt;How I built this&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;shot-scraper video&lt;/code&gt; started as an experimental prototype. &lt;code&gt;shot-scraper&lt;/code&gt; is built on top of &lt;a href="https://playwright.dev/"&gt;Playwright&lt;/a&gt;, and the key feature it needed was for Playwright to be able to record video of browser sessions with enough control to create the desired demo.&lt;/p&gt;
&lt;p&gt;I first tried this a few years ago and found that the Playwright-produced videos included additional chrome that was useful for debugging a test failure but unwanted for a product demo.&lt;/p&gt;
&lt;p&gt;They fixed that a while ago, but there were still some minor blockers. In particular I was getting &lt;a href="https://github.com/simonw/shot-scraper/pull/194/changes/c2f3b3a52ba84f2adcf3ad6da4d39c2570328584#issuecomment-4724459369"&gt;a few white frames at the start of the videos&lt;/a&gt;, since the recording mechanism kicked in before the first URL was loaded by the browser.&lt;/p&gt;
&lt;p&gt;Playwright 1.59 added a new &lt;a href="https://playwright.dev/python/docs/api/class-screencast"&gt;screencast mechanism&lt;/a&gt; providing much more finely grained control over video recording. This was very nearly what I needed, but the resulting videos were fixed at 800px wide.&lt;/p&gt;
&lt;p&gt;I found a &lt;a href="https://github.com/microsoft/playwright/pull/41183"&gt;landed PR fixing that&lt;/a&gt; but it wasn't yet in a release. Then yesterday they shipped it in &lt;a href="https://github.com/microsoft/playwright-python/releases/tag/v1.61.0"&gt;playwright-python 1.61.0&lt;/a&gt; and I was finally unblocked to finish implementing the feature!&lt;/p&gt;
&lt;p&gt;The code itself was all written by GPT-5.5 xhigh in Codex Desktop. I had it write the documentation as well which gave me a very useful frame for reviewing the design - much of the iteration on the feature came from reviewing that documentation, spotting things that were redundant, inconsistent or confusing, and requesting (or dictating) a better design.&lt;/p&gt;
&lt;p&gt;The YAML format itself was mostly defined by the coding agent. I had it &lt;a href="https://github.com/simonw/shot-scraper/blob/1.10/shot_scraper/video.py#L24"&gt;use Pydantic&lt;/a&gt; to both define and validate the format, partly to make the design easier to review.&lt;/p&gt;
&lt;p&gt;This is a great example of the kind of feature that I almost certainly wouldn't have taken on without coding agent support. I filed the &lt;a href="https://github.com/simonw/shot-scraper/issues/142"&gt;original issue&lt;/a&gt; in February 2024, and had difficulty finding the necessary time to solve this in amongst all of my other projects.&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="projects"/><category term="python"/><category term="yaml"/><category term="ai"/><category term="datasette"/><category term="playwright"/><category term="shot-scraper"/><category term="generative-ai"/><category term="llms"/><category term="pydantic"/><category term="coding-agents"/><category term="agentic-engineering"/></entry><entry><title>Porting the Moebius 0.2B image inpainting model to run in the browser with Claude Code</title><link href="https://simonwillison.net/2026/Jun/22/porting-moebius/#atom-entries" rel="alternate"/><published>2026-06-22T23:43:51+00:00</published><updated>2026-06-22T23:43:51+00:00</updated><id>https://simonwillison.net/2026/Jun/22/porting-moebius/#atom-entries</id><summary type="html">&lt;p&gt;This morning &lt;a href="https://news.ycombinator.com/item?id=48630171"&gt;on Hacker News&lt;/a&gt; I saw &lt;a href="https://hustvl.github.io/Moebius/"&gt;Moebius: 0.2B Lightweight Image Inpainting Framework with 10B-Level Performance&lt;/a&gt;, describing a small but effective inpainting model - a model where you can mark regions of an image to remove and the model imagines what should fill the space. The released model &lt;a href="https://github.com/hustvl/Moebius/blob/9310b76e368f5f7a8ecdf06493231af279c9973b/requirements.txt#L1"&gt;required PyTorch and NVIDIA CUDA&lt;/a&gt;, but since it described itself as 0.2B I decided to try and get it running using WebGPU in a browser. TL;DR: I got it working, and you can try the demo at &lt;a href="https://simonw.github.io/moebius-web/"&gt;simonw.github.io/moebius-web/&lt;/a&gt;. Read on for the details.&lt;/p&gt;
&lt;h4 id="the-finished-tool"&gt;The finished tool&lt;/h4&gt;
&lt;p&gt;Here's a video demo of the finished tool:&lt;/p&gt;

&lt;video
width="1280"
height="1070"
poster="https://static.simonwillison.net/static/2026/inpainting_1280_poster.jpg"
preload="none"
controls="controls"
playsinline="playsinline"
style="max-width:100%;height:auto"&gt;
&lt;source src="https://static.simonwillison.net/static/2026/inpainting_1280.mp4" type="video/mp4" /&gt;
&lt;/video&gt;

&lt;p&gt;You can open any image in it (non-square images get letterboxed), highlight areas to remove, click the "Run inpaint" button and wait for the model to do its magic.&lt;/p&gt;
&lt;h4 id="a-parallel-agent-side-project"&gt;A parallel agent side-project&lt;/h4&gt;
&lt;p&gt;My main project for today was landing a major feature in Datasette: a UI for creating and altering tables, as a follow-up to the &lt;a href="https://simonwillison.net/2026/Jun/16/datasette/"&gt;insert and edit rows feature&lt;/a&gt; I released last week.&lt;/p&gt;
&lt;p&gt;I was working on that in Codex Desktop (here's &lt;a href="https://github.com/simonw/datasette/pull/2789"&gt;the PR&lt;/a&gt;) and often found myself spending 5-10 minutes spinning my fingers waiting for it to complete a mid-sized refactor or add the finishing touches to a change to the UI.&lt;/p&gt;
&lt;p&gt;(An amusing thing about coding agents is that the harder a problem is the &lt;em&gt;more&lt;/em&gt; time you have to get distracted while you wait for them to finish crunching!)&lt;/p&gt;
&lt;p&gt;So I decided to spin up Claude Code in a terminal window and see how far I could get at porting Moebius to the web.&lt;/p&gt;
&lt;h4 id="some-agentic-research-to-kick-off-the-project"&gt;Some agentic research to kick off the project&lt;/h4&gt;
&lt;p&gt;My first step was to ask regular Claude about the feasibility of this project. In &lt;a href="https://claude.ai/"&gt;Claude.ai&lt;/a&gt;, which has the ability to clone repos from GitHub:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone https://github.com/hustvl/Moebius/ and tell me if they published the code and weights to run this model anywhere&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(I hadn't spotted the link to the weights yet, that's tucked away in the "News" section.)&lt;/p&gt;
&lt;p&gt;Then:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;For Moebius what are the options for running it right now - Python and NVIDIA CUDA only or other options too?&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Muse on the feasibility of porting it to Transformers.js or similar and running it in a browser&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I like telling models to "muse on X", it's the shortest way I've found of expressing that I want them to contemplate a problem for me without providing them with a concrete goal.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://claude.ai/share/551c3dc8-17ce-4a4b-a0c9-8cbded6c7bf1"&gt;that chat transcript&lt;/a&gt;. I copied out the last answer and saved it as &lt;a href="https://github.com/simonw/moebius-web/blob/main/research.md"&gt;research.md&lt;/a&gt; for Claude Code to read later.&lt;/p&gt;
&lt;p&gt;Claude suggested using &lt;strong&gt;ONNX Runtime Web on the WebGPU backend&lt;/strong&gt; - the layer &lt;em&gt;below&lt;/em&gt; the &lt;a href="https://huggingface.co/docs/transformers.js/en/index"&gt;Transformers.js&lt;/a&gt; library I had suggested.&lt;/p&gt;
&lt;p&gt;That was enough to convince me it was worth setting Claude Code loose and seeing how far it could get.&lt;/p&gt;
&lt;p&gt;I usually start projects like this by gathering as much information as the coding agent might need as possible. Since I didn't expect this project to actually work I did everything in my &lt;code&gt;/tmp&lt;/code&gt; folder:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;cd&lt;/span&gt; /tmp
mkdir Moebius
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; Moebius
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Grab the Moebius python code&lt;/span&gt;
git clone https://github.com/hustvl/Moebius
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; And the model weights (Claude figured this out):&lt;/span&gt;
GIT_LFS_SKIP_SMUDGE=0 git clone \
  https://huggingface.co/hustvl/Moebius Moebius-weights
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Finally a couple of libraries we might use:&lt;/span&gt;
git clone https://github.com/huggingface/transformers.js
git clone https://github.com/microsoft/onnxruntime&lt;/pre&gt;&lt;/div&gt;
&lt;h4 id="setting-off-claude-code"&gt;Setting off Claude Code&lt;/h4&gt;
&lt;p&gt;I created a directory for the rest of the project and ran &lt;code&gt;git init&lt;/code&gt; in that so Claude could start committing code notes:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;mkdir /tmp/Moebius/moebius-web
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; /tmp/Moebius/moebius-web
git init
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Copy in that research.md from earlier&lt;/span&gt;
git add research.md
git commit -m &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Initial research by Claude Opus 4.8&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I fired up a &lt;code&gt;claude&lt;/code&gt; instance in the &lt;code&gt;/tmp/Moebius&lt;/code&gt; folder, the level above all of the research materials I had prepared for it. I prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Read ./moebius-web/research.md - your goal is to port this model to ONNX and WebGPU so we can run it directly in a browser, with a simple UI&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;As it started to work I dropped in this follow-up (typos included):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Bulid this in /tmp/Moebius/moebius-web and commit early and often, also maintain a notes.md file in there with notes about what you figure out along the way - also start by writing out a plan.md in there and update that plan as oy work too&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I often ask agents to keep notes like this - the end result is often interesting, both for myself and for the next agent session that touches the same project. Here's what that &lt;a href="https://github.com/simonw/moebius-web/blob/main/notes.md"&gt;notes.md file&lt;/a&gt; looked like at the end of the project.&lt;/p&gt;
&lt;p&gt;I kicked it off and went back to my main project, checking in occasionally to see how Claude was doing. When it looked like it might have something that worked I prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Tell me what URL I can visit in my own browser to try this&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then I tried it out in Chrome and pasted some errors (and screenshots of errors) back into Claude Code.&lt;/p&gt;
&lt;p&gt;After a few rounds of this we had something that appeared to work! Time to put it on the internet so other people could use it.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;How would we publish this to Hugging Face such that the model weights were on there and the HTML demo would show up in Hugging Face spaces?&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Claude Code knows how to use the &lt;code&gt;hf&lt;/code&gt; CLI tool, so I created a model repo on &lt;a href="https://huggingface.co/"&gt;Hugging Face&lt;/a&gt;, then &lt;a href="https://huggingface.co/settings/tokens"&gt;created a token&lt;/a&gt; that could write to that repo and dropped it into a &lt;code&gt;/tmp/Moebius/token.txt&lt;/code&gt; file so Claude could use it.&lt;/p&gt;
&lt;p&gt;It published the 1.24GB of converted ONNX weights to &lt;a href="https://huggingface.co/simonw/Moebius-ONNX"&gt;huggingface.co/simonw/Moebius-ONNX&lt;/a&gt; for me.&lt;/p&gt;
&lt;p&gt;I'd seen other demos load weights into the browser from Hugging Face before, so I knew it was possible. I decided to host my own frontend code on GitHub Pages, so I said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;I want to publish the moebius-web folder to GitHub, minus the large files (so maybe minus the models/ folder), such that when I turn on GitHub Pages for that repo navigating to https://simonw.github.io/moebius-web/ serves the UI&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Telling it the final URL was important in case it needed to fix the URLs in the demos that it was building so they would work when deployed to production.&lt;/p&gt;
&lt;p&gt;After a few more rounds of iteration, in between working on my main project, we got to a working, deployed version!&lt;/p&gt;
&lt;p&gt;Except... each time I reloaded the page it seemed to download ~1.3GB of model weights. Browser caching seemed pretty important for this!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;anything clever we can do with serviceworkers or similar to help cache this stuff? It seems to reload every time, I am concerned that there might be something weird about the way HF redirects work that mean we don't benefit from browser caching&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I knew that Transformers.js projects could handle this properly, so I grabbed a copy of the &lt;a href="https://huggingface.co/spaces/Xenova/whisper-web"&gt;Whisper Web&lt;/a&gt; demo, dropped it into &lt;code&gt;/tmp/Moebius/whisper-web&lt;/code&gt; and said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;look in /tmp/Moebius/whisper-web (with a subagent) and see how they do this&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That project was entirely obfuscated, built JavaScript files so I figured using a subagent would avoid spending the rest of my top-level token context deciphering those files.&lt;/p&gt;
&lt;p&gt;Claude figured out that it was using &lt;code&gt;caches.open("transformers-cache")&lt;/code&gt; - the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage/open"&gt;CacheStorage API&lt;/a&gt; - and &lt;a href="https://github.com/simonw/moebius-web/commit/05c1cbc4894460a70a8bc1718ac6d152219e0f28#diff-fb89c342dfa36f544a2d16a885b0f3d1d49f436a7d0eaeb80505f80a1f922603"&gt;added that to our project&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I've shared the &lt;a href="https://gisthost.github.io/?58039ba5c1ca3ed177e8659168996ee4"&gt;full Claude Code transcript&lt;/a&gt; for this project (published using my &lt;a href="https://github.com/simonw/claude-code-transcripts"&gt;claude-code-transcripts&lt;/a&gt; tool).&lt;/p&gt;
&lt;h4 id="what-did-i-learn-from-all-of-this-"&gt;What did I learn from all of this?&lt;/h4&gt;
&lt;p&gt;This definitely counts as vibe coding: I didn't look at a single line of code from the project, restricting my input to testing, suggesting small feature improvements (like a progress bar for the large file downloads) and pointing the model in the direction of examples of how I wanted things to work.&lt;/p&gt;
&lt;p&gt;Since I didn't write any code the amount I learned about the underlying technologies - WebGPU, ONNX, and the Moebius model itself - was very limited.&lt;/p&gt;
&lt;p&gt;As is usually the case with this kind of project the most important things I learned concerned what was &lt;em&gt;possible&lt;/em&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Claude Opus 4.8 is capable of converting a PyTorch model to ONNX, publishing the result to Hugging Face and then building out a web application and interface that can load and execute that model.&lt;/li&gt;
&lt;li&gt;Chrome, Firefox and Safari are all now capable of running this kind of model - I tried it in all three.&lt;/li&gt;
&lt;li&gt;The CacheStorage API works with ~1.3GB model files.&lt;/li&gt;
&lt;li&gt;... which means we can have inpainting as a feature of a client-only web application! (If our users can tolerate the 1.3GB download.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I felt like I should probably try and learn a little more about my project. I fired up &lt;a href="https://claude.ai/"&gt;Claude.ai&lt;/a&gt; and prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone https://github.com/simonw/moebius-web/ and use it to teach me all about the model and ONNX and the process of converting a model to ONNX and WebGPU and basically everything I'd need to know in order to fully understand this repo&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://claude.ai/share/d11b8f2b-a52d-4ca2-be75-a710eaf18572"&gt;the transcript&lt;/a&gt; and the &lt;a href="https://github.com/simonw/moebius-web/blob/main/understanding.md"&gt;understanding.md&lt;/a&gt; Markdown file it created, which I've now added to the GitHub repo. I found the explanation of ONNX particularly enlightening:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ONNX&lt;/strong&gt; (Open Neural Network Exchange) is a portable, framework-neutral file format for neural networks. An &lt;code&gt;.onnx&lt;/code&gt; file is essentially two things bundled together:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A computation graph&lt;/strong&gt; — a directed graph of &lt;em&gt;nodes&lt;/em&gt;, where each node is an &lt;strong&gt;operator&lt;/strong&gt; (&lt;code&gt;Conv&lt;/code&gt;, &lt;code&gt;MatMul&lt;/code&gt;, &lt;code&gt;Add&lt;/code&gt;, &lt;code&gt;Einsum&lt;/code&gt;, &lt;code&gt;Softmax&lt;/code&gt;, &lt;code&gt;Gather&lt;/code&gt;, &lt;code&gt;Resize&lt;/code&gt;, …) wired together by named tensors flowing between them. This is the "recipe" for the forward pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The weights&lt;/strong&gt; — the learned parameter tensors (the convolution kernels, the embedding table, etc.), stored as initializers in that same graph.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Crucially, ONNX describes &lt;em&gt;what to compute&lt;/em&gt;, abstractly, without saying &lt;em&gt;how&lt;/em&gt; or &lt;em&gt;on what hardware&lt;/em&gt;. The operator set is versioned by an &lt;strong&gt;opset&lt;/strong&gt; number (this repo uses &lt;strong&gt;opset 18&lt;/strong&gt;), which pins down exactly which operators exist and what their semantics are.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It turns out PyTorch has built in mechanisms for exporting to ONNX, as seen &lt;a href="https://github.com/simonw/moebius-web/blob/080be6e737ec976130e260d34707d7d9b7f63d5b/python/export_onnx.py#L91"&gt;here in export_onnx.py&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;torch&lt;/span&gt;.&lt;span class="pl-c1"&gt;onnx&lt;/span&gt;.&lt;span class="pl-c1"&gt;export&lt;/span&gt;(
    &lt;span class="pl-s1"&gt;dec&lt;/span&gt;, (&lt;span class="pl-s1"&gt;lat&lt;/span&gt;,), &lt;span class="pl-s1"&gt;dec_path&lt;/span&gt;, &lt;span class="pl-s1"&gt;opset_version&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;args&lt;/span&gt;.&lt;span class="pl-c1"&gt;opset&lt;/span&gt;,
    &lt;span class="pl-s1"&gt;input_names&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s"&gt;"latent"&lt;/span&gt;], &lt;span class="pl-s1"&gt;output_names&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s"&gt;"image"&lt;/span&gt;],
    &lt;span class="pl-s1"&gt;dynamic_axes&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;{&lt;span class="pl-s"&gt;"latent"&lt;/span&gt;: {&lt;span class="pl-c1"&gt;0&lt;/span&gt;: &lt;span class="pl-s"&gt;"B"&lt;/span&gt;}, &lt;span class="pl-s"&gt;"image"&lt;/span&gt;: {&lt;span class="pl-c1"&gt;0&lt;/span&gt;: &lt;span class="pl-s"&gt;"B"&lt;/span&gt;}},
)&lt;/pre&gt;
&lt;p&gt;Claude also included a &lt;a href="https://github.com/simonw/moebius-web/blob/main/understanding.md#12-mini-glossary"&gt;handy glossary&lt;/a&gt; and an only-slightly-broken &lt;a href="https://github.com/simonw/moebius-web/blob/main/understanding.md#10-putting-the-whole-pipeline-in-one-picture"&gt;ASCII-art diagram&lt;/a&gt; showing how the model pipeline fits together.&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="browsers"/><category term="transformers-js"/><category term="webgl"/><category term="vibe-coding"/><category term="coding-agents"/><category term="claude-code"/><category term="onnx"/></entry><entry><title>sqlite-utils 4.0rc1 adds migrations and nested transactions</title><link href="https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/#atom-entries" rel="alternate"/><published>2026-06-21T23:35:47+00:00</published><updated>2026-06-21T23:35:47+00:00</updated><id>https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/#atom-entries</id><summary type="html">&lt;p&gt;&lt;a href="https://sqlite-utils.datasette.io/en/latest/"&gt;sqlite-utils&lt;/a&gt; is my combined Python library and CLI tool for working with SQLite databases. It provides an extensive set of higher-level operations on top of Python's default &lt;a href="https://docs.python.org/3/library/sqlite3.html"&gt;sqlite3 package&lt;/a&gt;, including support for &lt;a href="https://sqlite-utils.datasette.io/en/latest/cli.html#transforming-tables"&gt;complex table transformations&lt;/a&gt;, automatic table creation &lt;a href="https://sqlite-utils.datasette.io/en/latest/cli.html#inserting-json-data"&gt;from JSON data&lt;/a&gt; and a whole lot more.&lt;/p&gt;
&lt;p&gt;I released &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#rc1-2026-06-21"&gt;sqlite-utils 4.0rc1&lt;/a&gt;, the first release candidate for sqlite-utils v4. The major version bump indicates some (minor) backwards incompatible changes, so I'm interested in having people try this out before I commit to a stable release.&lt;/p&gt;
&lt;h4 id="new-feature-migrations"&gt;New feature: migrations&lt;/h4&gt;
&lt;p&gt;There are two significant new features in this RC compared to the previous 4.0 alphas.&lt;/p&gt;
&lt;p&gt;The first is support for &lt;strong&gt;database migrations&lt;/strong&gt;. This isn't a completely new implementation - it's a slightly modified port of the &lt;a href="https://github.com/simonw/sqlite-migrate"&gt;sqlite-migrate&lt;/a&gt; package I released a few years ago. I think that package has proved itself over time, so I'm now ready to bundle it with &lt;code&gt;sqlite-utils&lt;/code&gt; directly.&lt;/p&gt;
&lt;p&gt;Here's what a set of migrations in a &lt;code&gt;migrations.py&lt;/code&gt; file looks like:&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;Database&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;/pre&gt;
&lt;p&gt;This defines a set of two migrations, one creating the &lt;code&gt;creatures&lt;/code&gt; table and another adding a column to it.&lt;/p&gt;
&lt;p&gt;You can then run those migrations either using Python:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Database&lt;/span&gt;(&lt;span class="pl-s"&gt;"creatures.db"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;migrations&lt;/span&gt;.&lt;span class="pl-c1"&gt;apply&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Or with the command-line &lt;code&gt;migrate&lt;/code&gt; command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;sqlite-utils migrate creatures.db migrations.py&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The system is deliberately small: it doesn't provide reverse migrations, so any mistakes you make should be fixed by deploying a fresh migration to undo them.&lt;/p&gt;
&lt;p&gt;Its predecessor has been used by &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; and various other projects for several years, so I'm confident that the design is stable and works well.&lt;/p&gt;
&lt;p&gt;The new migrations feature &lt;a href="https://sqlite-utils.datasette.io/en/latest/migrations.html"&gt;is documented here&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="new-feature-db-atomic-transactions"&gt;New feature: db.atomic() transactions&lt;/h4&gt;
&lt;p&gt;This feature is a lot less exercised than migrations, so it deserves more attention from testers.&lt;/p&gt;
&lt;p&gt;Previously, &lt;code&gt;sqlite-utils&lt;/code&gt; mostly left transaction management up to its users, via a &lt;code&gt;with db.conn:&lt;/code&gt; construct that reused the &lt;code&gt;sqlite3&lt;/code&gt; mechanism directly.&lt;/p&gt;
&lt;p&gt;SQLite supports nested transactions in the form of savepoints, so I wanted an abstraction that could make those as easy to use as possible.&lt;/p&gt;
&lt;p&gt;I borrowed the terminology "atomic" from Django and Peewee. Here's what the new API looks like:&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-k"&gt;try&lt;/span&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;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;span class="pl-k"&gt;raise&lt;/span&gt; &lt;span class="pl-en"&gt;ValueError&lt;/span&gt;(&lt;span class="pl-s"&gt;"skip this one"&lt;/span&gt;)
    &lt;span class="pl-k"&gt;except&lt;/span&gt; &lt;span class="pl-v"&gt;ValueError&lt;/span&gt;:
        &lt;span class="pl-k"&gt;pass&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;3&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Marnie"&lt;/span&gt;})&lt;/pre&gt;
&lt;p&gt;More details &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#transactions-with-db-atomic"&gt;in the documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="backwards-incompatible-changes"&gt;Backwards incompatible changes&lt;/h4&gt;
&lt;p&gt;The backwards incompatible changes in v4 were described in the alpha release notes. For &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#a0-2025-05-08"&gt;4.0a0&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Upsert operations now use SQLite's &lt;code&gt;INSERT ... ON CONFLICT SET&lt;/code&gt; syntax on all SQLite versions later than 3.23.1. This is a very slight breaking change for apps that depend on the previous &lt;code&gt;INSERT OR IGNORE&lt;/code&gt; followed by &lt;code&gt;UPDATE&lt;/code&gt; behavior. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/652"&gt;#652&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Python library users can opt-in to the previous implementation by passing &lt;code&gt;use_old_upsert=True&lt;/code&gt; to the &lt;code&gt;Database()&lt;/code&gt; constructor, see &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-old-upsert"&gt;Alternative upserts using INSERT OR IGNORE&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Dropped support for Python 3.8, added support for Python 3.13. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/646"&gt;#646&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sqlite-utils tui&lt;/code&gt; is now provided by the &lt;a href="https://github.com/simonw/sqlite-utils-tui"&gt;sqlite-utils-tui&lt;/a&gt; plugin. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/648"&gt;#648&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Test suite now also runs against SQLite 3.23.1, the last version (from 2018-04-10) before the new &lt;code&gt;INSERT ... ON CONFLICT SET&lt;/code&gt; syntax was added. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/654"&gt;#654&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;And for &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#a1-2025-11-23"&gt;4.0a1&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The &lt;code&gt;db.table(table_name)&lt;/code&gt; method now only works with tables. To access a SQL view use &lt;code&gt;db.view(view_name)&lt;/code&gt; instead. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/657"&gt;#657&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;table.insert_all()&lt;/code&gt; and &lt;code&gt;table.upsert_all()&lt;/code&gt; methods can now accept an iterator of lists or tuples as an alternative to dictionaries. The first item should be a list/tuple of column names. See &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-insert-lists"&gt;Inserting data from a list or tuple iterator&lt;/a&gt; for details. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/672"&gt;#672&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The default floating point column type has been changed from &lt;code&gt;FLOAT&lt;/code&gt; to &lt;code&gt;REAL&lt;/code&gt;, which is the correct SQLite type for floating point values. This affects auto-detected columns when inserting data. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/645"&gt;#645&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Now uses &lt;code&gt;pyproject.toml&lt;/code&gt; in place of &lt;code&gt;setup.py&lt;/code&gt; for packaging. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/675"&gt;#675&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Tables in the Python API now do a much better job of remembering the primary key and other schema details from when they were first created. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/655"&gt;#655&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The &lt;code&gt;table.convert()&lt;/code&gt; and &lt;code&gt;sqlite-utils convert&lt;/code&gt; mechanisms no longer skip values that evaluate to &lt;code&gt;False&lt;/code&gt;. Previously the &lt;code&gt;--skip-false&lt;/code&gt; option was needed, this has been removed. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/542"&gt;#542&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: Tables created by this library now wrap table and column names in &lt;code&gt;"double-quotes"&lt;/code&gt; in the schema. Previously they would use &lt;code&gt;[square-braces]&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/677"&gt;#677&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;--functions&lt;/code&gt; CLI argument now accepts a path to a Python file in addition to accepting a string full of Python code. It can also now be specified multiple times. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/659"&gt;#659&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change:&lt;/strong&gt; Type detection is now the default behavior for the &lt;code&gt;insert&lt;/code&gt; and &lt;code&gt;upsert&lt;/code&gt; CLI commands when importing CSV or TSV data. Previously all columns were treated as &lt;code&gt;TEXT&lt;/code&gt; unless the &lt;code&gt;--detect-types&lt;/code&gt; flag was passed. Use the new &lt;code&gt;--no-detect-types&lt;/code&gt; flag to restore the old behavior. The &lt;code&gt;SQLITE_UTILS_DETECT_TYPES&lt;/code&gt; environment variable has been removed. (&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;h4 id="try-it-out"&gt;Try it out&lt;/h4&gt;
&lt;p&gt;You can install the new RC like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;pip install sqlite-utils==4.0rc1&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Or try the CLI version directly with &lt;code&gt;uvx&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx --with sqlite-utils==4.0rc1 sqlite-utils --help&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Come chat with us about it in the &lt;a href="https://discord.gg/Ass7bCAMDw"&gt;sqlite-utils Discord channel&lt;/a&gt;, or file any bugs in &lt;a href="https://github.com/simonw/sqlite-utils/issues"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="schema-migrations"/><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/></entry><entry><title>Datasette Apps: Host custom HTML applications inside Datasette</title><link href="https://simonwillison.net/2026/Jun/18/datasette-apps/#atom-entries" 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/#atom-entries</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;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&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>GLM-5.2 is probably the most powerful text-only open weights LLM</title><link href="https://simonwillison.net/2026/Jun/17/glm-52/#atom-entries" rel="alternate"/><published>2026-06-17T23:58:39+00:00</published><updated>2026-06-17T23:58:39+00:00</updated><id>https://simonwillison.net/2026/Jun/17/glm-52/#atom-entries</id><summary type="html">&lt;p&gt;Chinese AI lab &lt;a href="https://z.ai/"&gt;Z.ai&lt;/a&gt; released GLM-5.2 &lt;a href="https://x.com/Zai_org/status/2065704919299235870"&gt;to their coding plan subscribers&lt;/a&gt; on June 13th, and then yesterday (June 16th) released the full open weights under an MIT license. Similar in size to their previous GLM-5 and GLM-5.1 releases this is a 753B parameter, &lt;a href="https://huggingface.co/zai-org/GLM-5.2"&gt;1.51TB&lt;/a&gt; monster - with 40 active parameters (Mixture of Experts). GLM-5.2 is a text input only model - Z.ai have a separate vision family most recently represented by &lt;a href="https://x.com/Zai_org/status/2039371126984360085"&gt;GLM-5V-Turbo&lt;/a&gt;, but that one isn't open weights. GLM-5.2 has a 1 million token context window, up from GLM-5.1's 200,000.&lt;/p&gt;
&lt;p&gt;The buzz around this model is strong.&lt;/p&gt;
&lt;p&gt;Artificial Analysis, who run one of the most widely respected independent benchmarks: &lt;a href="https://artificialanalysis.ai/articles/glm-5-2-is-the-new-leading-open-weights-model-on-the-artificial-analysis-intelligence-index"&gt;GLM-5.2 is the new leading open weights model on the Artificial Analysis Intelligence Index&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;GLM-5.2 is the leading open weights model on the Intelligence Index v4.1.&lt;/strong&gt; At 51, it leads MiniMax-M3 (44), DeepSeek V4 Pro (max, 44) and Kimi K2.6 (43)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;They did however find it to be quite token-hungry:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;GLM-5.2 uses more output tokens per task than other leading open weights models:&lt;/strong&gt; the model uses 43k output tokens per Intelligence Index task, up from GLM-5.1 (26k) and above MiniMax-M3 (24k), Kimi K2.6 (35k) and DeepSeek V4 Pro (max, 37k)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The model is also now ranked 2nd on the &lt;a href="https://arena.ai/leaderboard/code/webdev"&gt;Code Arena WebDev leaderboard&lt;/a&gt;, behind only Claude Fable 5. That leaderboard measures "front-end web development tasks, including agentic coding workflows". I'm impressed to see it rank so highly given the lack of image input, which I had incorrectly assumed was a key part of building a truly great frontend coding model.&lt;/p&gt;
&lt;p&gt;I've been trying it out &lt;a href="https://openrouter.ai/z-ai/glm-5.2"&gt;via OpenRouter&lt;/a&gt;, which has it from 9 different providers, almost all of which are charging $1.40/million for input and $4.40/million for output. For comparison, GPT-5.5 is $5/$30 and Claude Opus 4.5-4.8 is $5/$25.&lt;/p&gt;
&lt;h4 id="excellent-pelican-disappointing-opossum"&gt;Excellent pelican, disappointing opossum&lt;/h4&gt;
&lt;p&gt;GLM-5.1 gave me &lt;a href="https://simonwillison.net/2026/Apr/7/glm-51/"&gt;one of my favorite pelicans&lt;/a&gt; and my &lt;a href="https://simonwillison.net/2026/Apr/7/glm-51/#opossum"&gt;all time favorite opossum&lt;/a&gt; (for the prompt "Generate an SVG of a NORTH VIRGINIA OPOSSUM ON AN E-SCOOTER".) Interestingly, in both of those cases the model chose to return SVG wrapped in an HTML document that added additional animations using CSS.&lt;/p&gt;
&lt;p&gt;Let's try GLM-5.2. For "Generate an SVG of a pelican riding a bicycle" I &lt;a href="https://gist.github.com/simonw/5c989366b796f054d9ae1ad7e38dc03a"&gt;got this&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/glm-5.2-pelican.svg" alt="It's a really good bicycle - all the right bits, spokes on the wheels, wheels and pedals rotating - and a very good pelican, red scarf, good beak, bobbing up and down. The feet don't stay on the pedals though." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;It's a self-contained fully animated SVG, and the animations aren't broken! Often I'll see eyes falling off or wheels rotating independently of the bicycle but here everything works great. It's a very nice vector illustration of a pelican too. Very impressive.&lt;/p&gt;
&lt;p&gt;Sadly, the NORTH VIRGINIA OPOSSUM ON AN E-SCOOTER did not come out &lt;a href="https://gist.github.com/simonw/5913b56e3d0ba9a2ece75ce1471f87bb"&gt;nearly as well&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/glm-5.2-opossum.svg" alt="Weird background gridlines, scooter is green and not very scooter like, possum is wearing a red safety helmet and has a hairy tail but is hardly recognizable as a possum. It's just bad." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This is such a step down from GLM-5.1! As a reminder, that possum looked like this:&lt;/p&gt;
&lt;p&gt;&lt;img alt="This is so great. It's dark, the possum is clearly a possum, it's riding an escooter, lovely animation, tail bobbing up and down, caption says NORTH VIRGINIA OPOSSUM, CRUISING THE COMMONWEALTH SINCE DUSK - only glitch is that it occasionally blinks and the eyes fall off the face" src="https://static.simonwillison.net/static/2026/glm-possum-escooter.gif.gif" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;5.2 didn't even &lt;em&gt;try&lt;/em&gt; to animate it.&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="pelican-riding-a-bicycle"/><category term="llm-release"/><category term="openrouter"/><category term="ai-in-china"/><category term="glm"/></entry><entry><title>Publishing WASM wheels to PyPI for use with Pyodide</title><link href="https://simonwillison.net/2026/Jun/13/publishing-wasm-wheels/#atom-entries" rel="alternate"/><published>2026-06-13T23:55:18+00:00</published><updated>2026-06-13T23:55:18+00:00</updated><id>https://simonwillison.net/2026/Jun/13/publishing-wasm-wheels/#atom-entries</id><summary type="html">&lt;p&gt;The &lt;a href="https://blog.pyodide.org/posts/314-release/"&gt;Pyodide 314.0 release announcement&lt;/a&gt; (via &lt;a href="https://news.ycombinator.com/item?id=48462759"&gt;Hacker News&lt;/a&gt;) includes news I've been looking forward to for a long time:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You can now publish Python packages built for Pyodide (or any Python runtime compatible with &lt;a href="https://pyodide.org/en/stable/development/abi.html"&gt;the PyEmscripten platform defined in PEP 783&lt;/a&gt;) directly to PyPI and install them at runtime.&lt;/p&gt;
&lt;p&gt;Previously, the Pyodide maintainers had to maintain, build, and host over 300 packages ourselves. This created a significant burden on our maintainers and became a major bottleneck for the community, as every new package required manual review.&lt;/p&gt;
&lt;p&gt;Moving forward, package maintainers can simply build and publish Pyodide wheels to PyPI, just as they do for native wheels on Linux, macOS, or Windows.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's the &lt;a href="https://github.com/pypi/warehouse/pull/19804"&gt;PR to PyPI itself supporting this&lt;/a&gt;, which landed on April 21st.&lt;/p&gt;
&lt;p&gt;I adore &lt;a href="https://pyodide.org"&gt;Pyodide&lt;/a&gt;, and have been frustrated in the past by this limitation. It's possible to compile C or Rust extensions to WASM in a wheel file, but before now there was no easy way to distribute them.&lt;/p&gt;
&lt;p&gt;Thanks to the efforts of a whole lot of people, that's now been fixed!&lt;/p&gt;
&lt;h4 id="trying-it-out-with-luau-wasm"&gt;Trying it out with luau-wasm&lt;/h4&gt;
&lt;p&gt;I decided to celebrate by finding something I could package. I have quite a few experimental Pyodide projects lying around, but the best fit for this looked to be my &lt;a href="https://github.com/simonw/research/tree/main/pluau-wasm-pyodide#readme"&gt;Luau WebAssembly research spike&lt;/a&gt; from 9th March.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://luau.org"&gt;Luau&lt;/a&gt; is a "small, fast, and embeddable programming language based on Lua with a gradual type system", &lt;a href="https://luau.org/news/2022-11-04-luau-origins-and-evolution/"&gt;developed by Roblox&lt;/a&gt; and released under an MIT license.&lt;/p&gt;
&lt;p&gt;It's written in C++. I already knew it was possible to compile it to WebAssembly and get it running inside of Pyodide, so I &lt;a href="https://gist.github.com/simonw/1761eab6ba11d4053f56f955a28ad76b"&gt;set Codex + GPT-5.5 xhigh&lt;/a&gt; the task of packaging my experiment up and publishing it to PyPI using GitHub Actions.&lt;/p&gt;
&lt;p&gt;It took some iteration, but here's the result: &lt;a href="https://pypi.org/project/luau-wasm/"&gt;luau-wasm&lt;/a&gt; is a brand new PyPI package which publishes a 276KB &lt;code&gt;luau_wasm-0.1a0-cp314-cp314-pyemscripten_2026_0_wasm32.whl&lt;/code&gt; file which can be used in Pyodide like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;
&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;.&lt;span class="pl-c1"&gt;install&lt;/span&gt;(&lt;span class="pl-s"&gt;"luau-wasm"&lt;/span&gt;)
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;luau_wasm&lt;/span&gt;
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;luau_wasm&lt;/span&gt;.&lt;span class="pl-c1"&gt;execute&lt;/span&gt;(&lt;span class="pl-s"&gt;r'''&lt;/span&gt;
&lt;span class="pl-s"&gt;local animals = {"fox", "owl", "frog", "rabbit"}&lt;/span&gt;
&lt;span class="pl-s"&gt;table.sort(animals, function(a, b) return #a &amp;lt; #b end)&lt;/span&gt;
&lt;span class="pl-s"&gt;for i, name in animals do print(i .. ". " .. name .. " (" .. #name .. ")") end&lt;/span&gt;
&lt;span class="pl-s"&gt;'''&lt;/span&gt;))&lt;/pre&gt;
&lt;p&gt;You can run that code &lt;a href="https://pyodide.org/en/stable/console.html"&gt;in the Pyodide REPL demo&lt;/a&gt; to see it in action.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/simonw/luau-wasm"&gt;GitHub repo for luau-wasm&lt;/a&gt; includes all of the build and deploy scripts (using the latest &lt;a href="https://github.com/pypa/cibuildwheel"&gt;cibuildwheel&lt;/a&gt;) and also deploys an HTML demo page which loads Pyodide, installs &lt;code&gt;luau-wasm&lt;/code&gt; and provides an interface for trying it out: &lt;a href="https://simonw.github.io/luau-wasm/"&gt;https://simonw.github.io/luau-wasm/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/luau-wasm.jpg" alt="Screenshot of a web app titled &amp;quot;Luau WASM&amp;quot; with subtitle &amp;quot;Run Luau in the browser through Pyodide after installing the luau-wasm WebAssembly wheel from PyPI.&amp;quot; A green &amp;quot;Ready&amp;quot; status badge is at top right. Below are example buttons: &amp;quot;Hello World&amp;quot;, &amp;quot;Variables&amp;quot;, &amp;quot;Tables&amp;quot;, &amp;quot;Fibonacci&amp;quot;, &amp;quot;Runtime Error&amp;quot;. A &amp;quot;LUAU SOURCE&amp;quot; code editor contains: local function fib(n: number): number / if n &amp;lt; 2 then return n end / return fib(n - 1) + fib(n - 2) / end / local out = {} / for i = 0, 12 do / table.insert(out, tostring(fib(i))) / end / print(table.concat(out, &amp;quot;, &amp;quot;)). On the right is an &amp;quot;OUTPUT&amp;quot; panel with a &amp;quot;Copy&amp;quot; button showing dark terminal output: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144. At the bottom left are a blue &amp;quot;Run&amp;quot; button, a &amp;quot;Clear&amp;quot; button, and the text &amp;quot;6.0 ms&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="how-many-packages-are-using-this-so-far-"&gt;How many packages are using this so far?&lt;/h4&gt;
&lt;p&gt;I was curious to see how many packages are currently publishing wheels for this platform.&lt;/p&gt;
&lt;p&gt;After some &lt;a href="https://chatgpt.com/share/6a2dee92-b110-83e8-9f53-ba9259b751ed"&gt;tinkering with ChatGPT&lt;/a&gt; I got to &lt;a href="https://gist.github.com/simonw/4a34a49fca63bc09c50bc31e17b3d33b?permalink_comment_id=6198602#gistcomment-6198602"&gt;this BigQuery SQL&lt;/a&gt; which I ran against PyPI's &lt;a href="https://packaging.python.org/en/latest/guides/analyzing-pypi-package-downloads/#public-dataset"&gt;public dataset on BigQuery&lt;/a&gt;. Here's the &lt;a href="https://gist.github.com/simonw/4a34a49fca63bc09c50bc31e17b3d33b"&gt;raw JSON&lt;/a&gt; of query results and here's a SQLite SQL query &lt;a href="https://lite.datasette.io/?json=https://gist.github.com/simonw/4a34a49fca63bc09c50bc31e17b3d33b#/data?sql=select%0A++name%2C%0A++platform_tag%2C%0A++matching_file_count%2C%0A++max%28latest_upload%29+as+latest_upload%2C%0A++example_files%0Afrom+%28%0A++--+your+existing+query+here%2C+without+order+by%0A++select%0A++++name%2C%0A++++platform_tag%2C%0A++++matching_file_count%2C%0A++++latest_upload%2C%0A++++example_files%0A++from+raw%0A%29%0Agroup+by+name%0Aorder+by+latest_upload+desc%3B"&gt;in Datasette Lite&lt;/a&gt; which dedupes packages by most recent upload date.&lt;/p&gt;
&lt;p&gt;If the query is right, there are currently 28 PyPI packages publishing with the new &lt;code&gt;pyemscripten_202*_wasm32&lt;/code&gt; tags:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://pypi.org/project/luau-wasm/"&gt;luau-wasm&lt;/a&gt;, &lt;a href="https://pypi.org/project/uuid7-rs/"&gt;uuid7-rs&lt;/a&gt;, &lt;a href="https://pypi.org/project/cmm-16bit/"&gt;cmm-16bit&lt;/a&gt;, &lt;a href="https://pypi.org/project/pyOpenTTDAdmin/"&gt;pyOpenTTDAdmin&lt;/a&gt;, &lt;a href="https://pypi.org/project/imgui-bundle/"&gt;imgui-bundle&lt;/a&gt;, &lt;a href="https://pypi.org/project/numbertoolkit/"&gt;numbertoolkit&lt;/a&gt;, &lt;a href="https://pypi.org/project/bashkit/"&gt;bashkit&lt;/a&gt;, &lt;a href="https://pypi.org/project/geoarrow-rust-core/"&gt;geoarrow-rust-core&lt;/a&gt;, &lt;a href="https://pypi.org/project/arro3-io/"&gt;arro3-io&lt;/a&gt;, &lt;a href="https://pypi.org/project/arro3-core/"&gt;arro3-core&lt;/a&gt;, &lt;a href="https://pypi.org/project/arro3-compute/"&gt;arro3-compute&lt;/a&gt;, &lt;a href="https://pypi.org/project/onnx/"&gt;onnx&lt;/a&gt;, &lt;a href="https://pypi.org/project/powerfit-em/"&gt;powerfit-em&lt;/a&gt;, &lt;a href="https://pypi.org/project/tcod/"&gt;tcod&lt;/a&gt;, &lt;a href="https://pypi.org/project/chonkie-core/"&gt;chonkie-core&lt;/a&gt;, &lt;a href="https://pypi.org/project/tokie/"&gt;tokie&lt;/a&gt;, &lt;a href="https://pypi.org/project/robotraconteur/"&gt;robotraconteur&lt;/a&gt;, &lt;a href="https://pypi.org/project/pydantic_core/"&gt;pydantic_core&lt;/a&gt;, &lt;a href="https://pypi.org/project/yaml-rs/"&gt;yaml-rs&lt;/a&gt;, &lt;a href="https://pypi.org/project/cadquery-ocp-novtk-OCP.wasm/"&gt;cadquery-ocp-novtk-OCP.wasm&lt;/a&gt;, &lt;a href="https://pypi.org/project/uuid_utils/"&gt;uuid_utils&lt;/a&gt;, &lt;a href="https://pypi.org/project/base64_utils/"&gt;base64_utils&lt;/a&gt;, &lt;a href="https://pypi.org/project/pycdfpp/"&gt;pycdfpp&lt;/a&gt;, &lt;a href="https://pypi.org/project/lib3mf-OCP.wasm/"&gt;lib3mf-OCP.wasm&lt;/a&gt;, &lt;a href="https://pypi.org/project/typst/"&gt;typst&lt;/a&gt;, &lt;a href="https://pypi.org/project/toml-rs/"&gt;toml-rs&lt;/a&gt;, &lt;a href="https://pypi.org/project/onnx-weekly/"&gt;onnx-weekly&lt;/a&gt;, &lt;a href="https://pypi.org/project/dummy-pyodide-ext-test/"&gt;dummy-pyodide-ext-test&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here's hoping we see a whole lot more of those showing up over the coming months and years.&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="lua"/><category term="pypi"/><category term="python"/><category term="sandboxing"/><category term="webassembly"/><category term="github-actions"/><category term="pyodide"/></entry><entry><title>Claude Fable is relentlessly proactive</title><link href="https://simonwillison.net/2026/Jun/11/fable-is-relentlessly-proactive/#atom-entries" 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/#atom-entries</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;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&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>Initial impressions of Claude Fable 5</title><link href="https://simonwillison.net/2026/Jun/9/claude-fable-5/#atom-entries" rel="alternate"/><published>2026-06-09T23:59:54+00:00</published><updated>2026-06-09T23:59:54+00:00</updated><id>https://simonwillison.net/2026/Jun/9/claude-fable-5/#atom-entries</id><summary type="html">&lt;p&gt;I didn't have early access to today's &lt;a href="https://www.anthropic.com/news/claude-fable-5-mythos-5"&gt;Claude Fable 5&lt;/a&gt; release, but I've spent the past ~5.5 hours putting it through its paces. My initial impressions are that this is something of a &lt;em&gt;beast&lt;/em&gt;. It's slow, expensive and has been quite happily churning through everything I've thrown at it so far. As is frequently the case with current frontier models the challenge is finding tasks that it can't do.&lt;/p&gt;
&lt;p&gt;First, let's review the key characteristics.&lt;/p&gt;
&lt;p&gt;Anthropic claim that &lt;a href="https://www.anthropic.com/news/claude-fable-5-mythos-5"&gt;Claude Fable 5&lt;/a&gt; offers the same performance as Claude Mythos 5, except with much more strict guardrails in place to prevent it being used for harmful things. Those guardrails trigger often enough that the Claude API has new mechanisms for letting you know when you hit them, and even has a &lt;a href="https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback"&gt;new option&lt;/a&gt; to request it falls back to another model automatically if something gets rejected.&lt;/p&gt;
&lt;p&gt;Claude Mythos 5 is out today as well, &lt;a href="https://platform.claude.com/docs/en/about-claude/models/introducing-claude-fable-5-and-claude-mythos-5"&gt;Anthropic say it&lt;/a&gt; "Shares Claude Fable 5's capabilities without the safety classifiers". Mythos remains limited to a small pool trusted partners.&lt;/p&gt;
&lt;p&gt;Both models have a 1 million token context window, 128,000 maximum output tokens and a knowledge cut-off date of January 2026.&lt;/p&gt;
&lt;p&gt;They are priced at twice the price of Claude Opus 4.5/4.6/4.7/4.8: $10/million input tokens and $50/million output tokens. There's no increase in price for longer context usage.&lt;/p&gt;
&lt;p&gt;Other than that the &lt;a href="https://platform.claude.com/docs/en/about-claude/models/introducing-claude-fable-5-and-claude-mythos-5"&gt;upgrade guide&lt;/a&gt; is substantially thinner than &lt;a href="https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-8"&gt;the similar guide for Opus 4.8&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="the-big-model-smell"&gt;The big model smell&lt;/h4&gt;
&lt;p&gt;The best way to describe Fable is that it feels &lt;em&gt;big&lt;/em&gt;. Not just in terms of speed and cost, but also in how much it knows.&lt;/p&gt;
&lt;p&gt;Here's an example prompt, which I used to compare Fable's knowledge with Opus 4.8 (typo included):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;List all of Simon Willion's open source projects, most recent first, each with a rough date of when they were first released&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Opus 4.8's reply &lt;a href="https://gist.github.com/simonw/e77abf645156ade8c5c610f524226dcb#response-1"&gt;started like this&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I don't have a reliable, comprehensive, and date-verified list of all of Simon Willison's open source projects, and I want to be honest about that rather than risk giving you inaccurate dates or fabricated entries.&lt;/p&gt;
&lt;p&gt;Here's what I can tell you with reasonable confidence:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Well-known projects by Simon Willison:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;LLM&lt;/strong&gt; (~2023) – A command-line tool and Python library for interacting with large language models.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Datasette&lt;/strong&gt; (~2017) – A tool for exploring and publishing data, probably his most prominent recent project.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;sqlite-utils&lt;/strong&gt; (~2018-2019) – A Python library and CLI tool for manipulating SQLite databases, often used alongside Datasette.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Django&lt;/strong&gt; (2005) – He is one of the co-creators of the Django web framework, along with Adrian Holovaty, originally developed at the Lawrence Journal-World newspaper.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;He also maintains a large ecosystem of &lt;strong&gt;Datasette plugins&lt;/strong&gt; and many smaller utilities (often with names like &lt;code&gt;shot-scraper&lt;/code&gt;, various &lt;code&gt;*-to-sqlite&lt;/code&gt; tools for importing data, etc.).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's not bad. Here's &lt;a href="https://gist.github.com/simonw/e77abf645156ade8c5c610f524226dcb#response"&gt;the start of the response&lt;/a&gt; for the exact same prompt from Fable 5 - both models were running without access to search:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I think you mean &lt;strong&gt;Simon Willison&lt;/strong&gt; — the prolific open source developer, co-creator of Django, and creator of Datasette.&lt;/p&gt;
&lt;p&gt;An important caveat upfront: a truly complete list isn't feasible. Simon Willison is famously prolific — he has published &lt;strong&gt;hundreds&lt;/strong&gt; of repositories (Datasette alone has 100+ plugins, many written by him), and my knowledge has a cutoff date, so I'll be missing his most recent work. Below are his &lt;strong&gt;notable&lt;/strong&gt; projects with approximate initial release dates, most recent first:&lt;/p&gt;
&lt;h2 id="notable-projects-approximate-dates-"&gt;Notable projects (approximate dates)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;files-to-prompt&lt;/strong&gt; (April 2024) – concatenates files into a single prompt for LLMs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;datasette-extract&lt;/strong&gt; (2024) – plugin for extracting structured data using LLMs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM&lt;/strong&gt; (May–June 2023) – CLI tool and Python library for interacting with large language models, plus a large ecosystem of plugins (llm-gpt4all, llm-mistral, llm-claude, etc., 2023 onward)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;symbex&lt;/strong&gt; (June 2023) – search Python code for symbols&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ttok&lt;/strong&gt; and &lt;strong&gt;strip-tags&lt;/strong&gt; (May 2023) – CLI tools for token counting and HTML cleanup for LLM pipelines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;datasette-lite&lt;/strong&gt; (May 2022) – Datasette running entirely in the browser via WebAssembly/Pyodide&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;shot-scraper&lt;/strong&gt; (March 2022) – automated website screenshots via Playwright&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;s3-credentials&lt;/strong&gt; (November 2021) – CLI for creating scoped AWS S3 credentials&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;django-sql-dashboard&lt;/strong&gt; (2021) – SQL reporting dashboards for Django&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dogsheep suite&lt;/strong&gt; (2019) – personal analytics tools: twitter-to-sqlite, github-to-sqlite, healthkit-to-sqlite, dogsheep-beta, etc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sqlite-utils&lt;/strong&gt; (2018) – CLI and Python library for manipulating SQLite databases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Datasette&lt;/strong&gt; (November 2017) – his flagship project; tool for exploring and publishing data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;csvs-to-sqlite&lt;/strong&gt; (2017) – convert CSV files to SQLite&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Various early tools&lt;/strong&gt; (~2007–2010) – soupselect, json-head, geocoders, and others&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Django&lt;/strong&gt; (developed 2003–2005, open-sourced July 2005) – co-created with Adrian Holovaty at the Lawrence Journal-World&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;(Here's &lt;a href="https://gist.github.com/simonw/8d05ce836d44bd8543055c9614f7c478"&gt;GPT-5.5 for good measure&lt;/a&gt;. It listed even more projects than Fable did!)&lt;/p&gt;
&lt;p&gt;In the past I've stated that I don't care about how much models &lt;em&gt;know&lt;/em&gt; - I want them to be able to manipulate text and code in useful ways and actively look up the information they need via search tools, not bake it into their weights.&lt;/p&gt;
&lt;p&gt;But knowledge like this is a reasonably good proxy for model size - you can cram a whole lot more details about the world into a larger number of parameters.&lt;/p&gt;
&lt;p&gt;Does &lt;em&gt;knowing more stuff&lt;/em&gt; mean the model is better at the tasks we pose to it? I can certainly imagine how a coding model with deeper knowledge of modern libraries and patterns could crunch through coding tasks more effectively.&lt;/p&gt;
&lt;p&gt;Is Fable really bigger than Opus? Anthropic haven't said anything about model size, so all we have are tea-leaves, but the speed, pricing and my own poking at its knowledge make me think that it's a large model. Maybe the largest yet from any vendor.&lt;/p&gt;
&lt;h4 id="using-fable-in-claude-ai"&gt;Using Fable in Claude.ai&lt;/h4&gt;
&lt;p&gt;Anthropic made Fable 5 available across all of their surfaces - the &lt;a href="https://claude.ai/"&gt;Claude.ai&lt;/a&gt; chat interface, Claude Code for web, Claude Code CLI and Claude Cowork as well. The model is available "until June 22nd" on the subscription plans (I'm on $100/month Max at the moment), after which it will be billed extra.&lt;/p&gt;
&lt;p&gt;Claude.ai is often under-estimated. Since &lt;a href="https://simonwillison.net/2025/Sep/9/claude-code-interpreter/"&gt;September 2025&lt;/a&gt; every chat has had access to a full container environment to run code, including the ability to install additional packages and even clone repositories directly from GitHub.&lt;/p&gt;
&lt;p&gt;Last week &lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/"&gt;I released micropython-wasm&lt;/a&gt;, a Python library that uses &lt;a href="https://wasmtime.dev/"&gt;wasmtime&lt;/a&gt; to run a custom build of MicroPython in WebAssembly to act as a sandbox for untrusted Python code.&lt;/p&gt;
&lt;p&gt;I decided to see if Fable could upgrade that to running full Python instead. I started with this prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone simonw/micropython-wasm from GitHub and research how this could use a full Python as opposed to MicroPython&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Fable identified that it could use Brett Cannon's &lt;a href="https://github.com/brettcannon/cpython-wasi-build/releases"&gt;cpython-wasi-build&lt;/a&gt; builds for this, but was unable to download them itself due to environment restrictions.&lt;/p&gt;
&lt;p&gt;So I grabbed the two zip files from that page and uploaded them to Claude:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Here's the Brett Cannon builds&lt;/code&gt; (&lt;code&gt;python-3.zip&lt;/code&gt;, &lt;code&gt;_build-python-3.zip&lt;/code&gt; as attachments)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And that was that. It churned away for a few minutes and got the entire thing working.&lt;/p&gt;
&lt;p&gt;Part of the response included:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I tried the cleaner single-zip-stdlib approach to shrink the filesystem surface, but CPython's &lt;code&gt;getpath&lt;/code&gt; bootstrap fails to find &lt;code&gt;encodings&lt;/code&gt; from inside a zip without more prefix finessing — the directory-preopen approach works reliably, so that's what the PoC uses. The zip path is solvable but needs &lt;code&gt;_PYTHONHOME&lt;/code&gt;/frozen-getpath work.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So I said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Try a bit more at the single-zip-stdlib problem&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then a little later:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;I want a wheel that has the whole system in it, the Python wrappers and the WASM files and the stdlibrary, so I can do uv run --with path-to-whl python -c "demo code"&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;... and it gave me &lt;a href="https://static.simonwillison.net/static/cors-allow/2026/cpython_wasm-0.1.0-py3-none-any.whl"&gt;this 13.9MB cpython_wasm-0.1.0-py3-none-any.whl&lt;/a&gt; file. You can try running Python code in a sandbox using that wheel URL and &lt;code&gt;uv&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --with https://static.simonwillison.net/static/cors-allow/2026/cpython_wasm-0.1.0-py3-none-any.whl \
  cpython-wasm -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;print(45 ** 56)&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's &lt;a href="https://claude.ai/share/a73b8b8b-8ebc-4fef-9e5c-7438e5e7ae35"&gt;the full chat transcript&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This was a &lt;em&gt;very&lt;/em&gt; strong start.&lt;/p&gt;
&lt;h4 id="adding-features-to-datasette-agent-and-llm-using-claude-code"&gt;Adding features to Datasette Agent and LLM using Claude Code&lt;/h4&gt;
&lt;p&gt;Before I'd realized it was Fable day, my stretch goal for today was to add a new feature to &lt;a href="https://agent.datasette.io/"&gt;Datasette Agent&lt;/a&gt;: I wanted tool calls within that agent software to gain the ability to pause mid-execution and request approval directly from the user.&lt;/p&gt;
&lt;p&gt;This felt like a suitably meaty task to throw at the new model.&lt;/p&gt;
&lt;p&gt;Over the course of the day Fable not only &lt;a href="https://github.com/datasette/datasette-agent/pull/20"&gt;solved that problem&lt;/a&gt;, it also identified and then implemented four issues in my underlying LLM library that would help support this kind of advanced pause-resume mechanism in tool calls.&lt;/p&gt;
&lt;p&gt;It got everything working first using somewhat gnarly hacks, but the moment I told it that changes to LLM itself were in scope it set to work unraveling the hacks and turning them into supported features of LLM instead.&lt;/p&gt;
&lt;p&gt;My stretch goal turned into &lt;a href="https://llm.datasette.io/en/latest/changelog.html#a3-2026-06-09"&gt;LLM 0.32a3&lt;/a&gt;, almost entirely written by Fable. Here are the release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Driven by the needs of &lt;a href="https://github.com/datasette/datasette-agent"&gt;Datasette Agent&lt;/a&gt;'s human-in-the-loop &lt;code&gt;ask_user()&lt;/code&gt; feature, made the following improvements to how tool calls work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tool implementations can declare a parameter named &lt;code&gt;llm_tool_call&lt;/code&gt; in order to be passed the &lt;code&gt;llm.ToolCall&lt;/code&gt; object for the current invocation. This allows them to access the current &lt;code&gt;llm_tool_call.tool_call_id&lt;/code&gt;. See &lt;a href="https://llm.datasette.io/en/latest/python-api.html#python-api-tools-llm-tool-call"&gt;Accessing the tool call from inside a tool&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/1480"&gt;#1480&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Every tool call is now guaranteed a unique &lt;code&gt;tool_call_id&lt;/code&gt; - providers that do not supply one get a synthesized &lt;code&gt;tc_&lt;/code&gt;-prefixed ULID. &lt;a href="https://github.com/simonw/llm/pull/1481"&gt;#1481&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tools can raise a &lt;code&gt;llm.PauseChain&lt;/code&gt; exception to cleanly pause the tool chain, useful for things like waiting for human approval. The exception propagates to the caller with &lt;code&gt;.tool_call&lt;/code&gt; and &lt;code&gt;.tool_results&lt;/code&gt; (completed sibling results) attached, and no model call is made with a placeholder result. See &lt;a href="https://llm.datasette.io/en/latest/python-api.html#python-api-tools-pause"&gt;Pausing a chain from inside a tool&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/1482"&gt;#1482&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Failure semantics for concurrent tool execution: async sibling tool calls always run to completion before a pause or hook exception propagates. &lt;a href="https://github.com/simonw/llm/pull/1482"&gt;#1482&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Chains can now resume from a &lt;code&gt;messages=&lt;/code&gt; history ending in unresolved tool calls: the calls are executed through the normal &lt;code&gt;before_call&lt;/code&gt;/&lt;code&gt;after_call&lt;/code&gt; machinery before the first model call, skipping any that already have results. The &lt;code&gt;execute_tool_calls()&lt;/code&gt; method also accepts a new optional &lt;code&gt;tool_calls_list=&lt;/code&gt; argument for executing an explicit list of &lt;code&gt;ToolCall&lt;/code&gt; objects in place of the calls requested by the response. See &lt;a href="https://llm.datasette.io/en/latest/python-api.html#python-api-tools-resume"&gt;Resuming a chain with pending tool calls&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/1482"&gt;#1482&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fixed a bug where the async tool executor silently dropped calls to tools not present in &lt;code&gt;tools=&lt;/code&gt; - these now return &lt;code&gt;Error: tool "..." does not exist&lt;/code&gt; results, matching the sync executor. &lt;a href="https://github.com/simonw/llm/pull/1483"&gt;#1483&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I'm really impressed with the quality of API design, tests, code and documentation that Fable put together for this. I spent several hours on it today, but it feels like several days' worth of work.&lt;/p&gt;
&lt;h4 id="how-much-i-ve-spent"&gt;How much I've spent&lt;/h4&gt;
&lt;p&gt;I recently started using &lt;a href="https://agentsview.io"&gt;AgentsView&lt;/a&gt; to help track my local LLM usage across all of the different coding agents. I published a &lt;a href="https://til.simonwillison.net/llms/agentsview-custom-model-price"&gt;TIL today&lt;/a&gt; about adding custom Fable pricing to that tool, which I expect will not be necessary in the very near future.&lt;/p&gt;
&lt;p&gt;After setting the price, I ran this command to start a localhost web server to explore my usage:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx agentsview serve
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's the treemap showing the breakdown of my Fable usage across various projects today:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/agentsview-fable-full-day.jpg" alt="Screenshot of a cost tracking dashboard with two panels. The first panel is titled &amp;quot;Cost Attribution&amp;quot; with toggle buttons for Project / Model / Agent and Treemap / List, with Project and Treemap selected. Italic text reads &amp;quot;Click to hide from chart&amp;quot;. A treemap shows a large red block labeled prod_datasette_agent $99.26 89.9%, with smaller blocks to its right labeled cloud (blue), datasette (teal), llm (red), and money (pink), plus a tiny orange sliver. A legend lists: 1 prod_datasette_agent $99.26, 2 cloud $3.98, 3 datasette $2.81, 4 llm $2.30, 5 money $1.92, 6 simon $0.15. The second panel is titled &amp;quot;Top Sessions by Cost&amp;quot; and lists nine sessions, each with a &amp;quot;Claude&amp;quot; badge, a prompt excerpt, a project name with a session UUID (omitted here), a token count, and a cost: 1. Review ./datasette-agent and ./datasette-apps - we are going to add a new feature to agent but you ... prod_datasette_agent, 78.2M, $99.26. 2. issues.db is a copy of the Datasette issues database. There are a LOT of notes in there relating to... datasette, 826.8k, $2.81. 3. Consult fly-docs and then look at datasette.cloud (which launches fly machines) and datasettecloud-... cloud, 924.7k, $2.61. 4. simonwillisonblog.db is a copy of my blog, plus all my software releases and other interesting thin... money, 542.9k, $1.92. 5. Look in datasette.cloud and figure out all remaining steps and decisions that need to be made in or... cloud, 455k, $1.37. 6. Review PRs and issues filed against this repo within the last 4 weeks and see if any deserve to be ... llm, 323.3k, $0.95. 7. run mypy, llm, 320.9k, $0.76. 8. [Image #1] fix this in github actions, llm, 183.9k, $0.59. 9. simon, simon, 26.4k, $0.15." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I used $110.42 worth of tokens today, all as part of my $100/month subscription.&lt;/p&gt;
&lt;h4 id="and-some-pelicans"&gt;And some pelicans&lt;/h4&gt;
&lt;p&gt;I ran "Generate an SVG of a pelican riding a bicycle" against all five thinking effort levels with Fable.&lt;/p&gt;
&lt;p&gt;Here are &lt;a href="https://tools.simonwillison.net/markdown-svg-renderer#url=https%3A%2F%2Fgist.github.com%2Fsimonw%2F94fde31c34a0400c1d29f57e6a708e6b"&gt;the results&lt;/a&gt;, including the token cost for each one:&lt;/p&gt;

&lt;div style="display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 1em"&gt;
  &lt;figure style="margin: 0; flex: 1 1 30%;"&gt;
    &lt;img src="https://static.simonwillison.net/static/2026/fable-low.jpg" alt="low" style="width: 100%; height: auto;" /&gt;
    &lt;figcaption style="text-align: center;"&gt;low: &lt;a href="https://www.llm-prices.com/#it=25&amp;amp;ot=1929&amp;amp;sel=claude-fable-5"&gt;1,929 out, 9.67c&lt;/a&gt;&lt;/figcaption&gt;
  &lt;/figure&gt;
  &lt;figure style="margin: 0; flex: 1 1 30%;"&gt;
    &lt;img src="https://static.simonwillison.net/static/2026/fable-medium.jpg" alt="medium" style="width: 100%; height: auto;" /&gt;
    &lt;figcaption style="text-align: center;"&gt;medium: &lt;a href="https://www.llm-prices.com/#it=25&amp;amp;ot=2290&amp;amp;sel=claude-fable-5"&gt;2,290 out, 11.475c&lt;/a&gt;&lt;/figcaption&gt;
  &lt;/figure&gt;
  &lt;figure style="margin: 0; flex: 1 1 30%;"&gt;
    &lt;img src="https://static.simonwillison.net/static/2026/fable-high.jpg" alt="high" style="width: 100%; height: auto;" /&gt;
    &lt;figcaption style="text-align: center;"&gt;high: &lt;a href="https://www.llm-prices.com/#it=25&amp;amp;ot=2057&amp;amp;sel=claude-fable-5"&gt;2,057 out, 10.31c&lt;/a&gt;&lt;/figcaption&gt;
  &lt;/figure&gt;
  &lt;figure style="margin: 0; flex: 1 1 45%;"&gt;
    &lt;img src="https://static.simonwillison.net/static/2026/fable-xhigh.jpg" alt="xhigh" style="width: 100%; height: auto;" /&gt;
    &lt;figcaption style="text-align: center;"&gt;xhigh: &lt;a href="https://www.llm-prices.com/#it=25&amp;amp;ot=5992&amp;amp;sel=claude-fable-5"&gt;5,992 out, 29.985c&lt;/a&gt;&lt;/figcaption&gt;
  &lt;/figure&gt;
  &lt;figure style="margin: 0; flex: 1 1 45%;"&gt;
    &lt;img src="https://static.simonwillison.net/static/2026/fable-max.jpg" alt="max" style="width: 100%; height: auto;" /&gt;
    &lt;figcaption style="text-align: center;"&gt;max: &lt;a href="https://www.llm-prices.com/#it=25&amp;amp;ot=14430&amp;amp;sel=claude-fable-5"&gt;14,430 out, 72.175c&lt;/a&gt;&lt;/figcaption&gt;
  &lt;/figure&gt;
&lt;/div&gt;

&lt;p&gt;It's interesting that high ended up using fewer tokens than medium for this particular run.&lt;/p&gt;

&lt;p&gt;Here are the &lt;a href="https://simonwillison.net/2026/May/28/claude-opus-4-8/#and-some-pelicans"&gt;Opus 4.8 pelicans&lt;/a&gt; for comparison.&lt;/p&gt;&lt;p&gt;&lt;em&gt;You are only seeing the long-form articles from my blog. Subscribe to &lt;a href="https://simonwillison.net/atom/everything/"&gt;/atom/everything/&lt;/a&gt; to get all of my posts, or take a look at my &lt;a href="https://simonwillison.net/about/#subscribe"&gt;other subscription options&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</summary><category term="ai"/><category term="generative-ai"/><category term="llms"/><category term="anthropic"/><category term="claude"/><category term="llm-pricing"/><category term="pelican-riding-a-bicycle"/><category term="llm-release"/><category term="claude-mythos-fable"/></entry></feed>