<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: projects</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/projects.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-04-06T02:59:28+00:00</updated><author><name>Simon Willison</name></author><entry><title>scan-for-secrets 0.3</title><link href="https://simonwillison.net/2026/Apr/6/scan-for-secrets/#atom-tag" rel="alternate"/><published>2026-04-06T02:59:28+00:00</published><updated>2026-04-06T02:59:28+00:00</updated><id>https://simonwillison.net/2026/Apr/6/scan-for-secrets/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/scan-for-secrets/releases/tag/0.3"&gt;scan-for-secrets 0.3&lt;/a&gt;&lt;/p&gt;
    &lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;-r/--redact&lt;/code&gt; option which shows the list of matches, asks for confirmation and then replaces every match with &lt;code&gt;REDACTED&lt;/code&gt;, taking escaping rules into account.&lt;/li&gt;
&lt;li&gt;New Python function &lt;code&gt;redact_file(file_path: str | Path, secrets: list[str], replacement: str = "REDACTED") -&amp;gt; int&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/></entry><entry><title>scan-for-secrets 0.1</title><link href="https://simonwillison.net/2026/Apr/5/scan-for-secrets-3/#atom-tag" rel="alternate"/><published>2026-04-05T03:27:13+00:00</published><updated>2026-04-05T03:27:13+00:00</updated><id>https://simonwillison.net/2026/Apr/5/scan-for-secrets-3/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/scan-for-secrets/releases/tag/0.1"&gt;scan-for-secrets 0.1&lt;/a&gt;&lt;/p&gt;
    &lt;p&gt;I like publishing transcripts of local Claude Code sessions using my &lt;a href="https://github.com/simonw/claude-code-transcripts"&gt;claude-code-transcripts&lt;/a&gt; tool but I'm often paranoid that one of my API keys or similar secrets might inadvertently be revealed in the detailed log files.&lt;/p&gt;
&lt;p&gt;I built this new Python scanning tool to help reassure me. You can feed it secrets and have it scan for them in a specified directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx scan-for-secrets $OPENAI_API_KEY -d logs-to-publish/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you leave off the &lt;code&gt;-d&lt;/code&gt; it defaults to the current directory.&lt;/p&gt;
&lt;p&gt;It doesn't just scan for the literal secrets - it also scans for common encodings of those secrets e.g. backslash or JSON escaping, &lt;a href="https://github.com/simonw/scan-for-secrets/blob/main/README.md#escaping-schemes"&gt;as described in the README&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you have a set of secrets you always want to protect you can list commands to echo them in a &lt;code&gt;~/.scan-for-secrets.conf.sh&lt;/code&gt; file. Mine looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm keys get openai
llm keys get anthropic
llm keys get gemini
llm keys get mistral
awk -F= '/aws_secret_access_key/{print $2}' ~/.aws/credentials | xargs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I built this tool using README-driven-development: I carefully constructed the README describing exactly how the tool should work, then &lt;a href="https://gisthost.github.io/?d4b1a398bf3b6b14aade923dea69a1ac/index.html"&gt;dumped it into Claude Code&lt;/a&gt; and told it to build the actual tool (using &lt;a href="https://simonwillison.net/guides/agentic-engineering-patterns/red-green-tdd/"&gt;red/green TDD&lt;/a&gt;, naturally.)&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="security"/><category term="agentic-engineering"/><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="claude-code"/></entry><entry><title>Writing about Agentic Engineering Patterns</title><link href="https://simonwillison.net/2026/Feb/23/agentic-engineering-patterns/#atom-tag" rel="alternate"/><published>2026-02-23T17:43:02+00:00</published><updated>2026-02-23T17:43:02+00:00</updated><id>https://simonwillison.net/2026/Feb/23/agentic-engineering-patterns/#atom-tag</id><summary type="html">
    &lt;p&gt;I've started a new project to collect and document &lt;strong&gt;&lt;a href="https://simonwillison.net/guides/agentic-engineering-patterns/"&gt;Agentic Engineering Patterns&lt;/a&gt;&lt;/strong&gt; - coding practices and patterns to help get the best results out of this new era of coding agent development we find ourselves entering.&lt;/p&gt;
&lt;p&gt;I'm using &lt;strong&gt;Agentic Engineering&lt;/strong&gt; to refer to building software using coding agents - tools like Claude Code and OpenAI Codex, where the defining feature is that they can both generate and &lt;em&gt;execute&lt;/em&gt; code - allowing them to test that code and iterate on it independently of turn-by-turn guidance from their human supervisor.&lt;/p&gt;
&lt;p&gt;I think of &lt;strong&gt;vibe coding&lt;/strong&gt; using its &lt;a href="https://simonwillison.net/2025/Mar/19/vibe-coding/"&gt;original definition&lt;/a&gt; of coding where you pay no attention to the code at all, which today is often associated with non-programmers using LLMs to write code.&lt;/p&gt;
&lt;p&gt;Agentic Engineering represents the other end of the scale: professional software engineers using coding agents to improve and accelerate their work by amplifying their existing expertise.&lt;/p&gt;
&lt;p&gt;There is so much to learn and explore about this new discipline! I've already published a lot &lt;a href="https://simonwillison.net/tags/ai-assisted-programming/"&gt;under my ai-assisted-programming tag&lt;/a&gt; (345 posts and counting) but that's been relatively unstructured. My new goal is to produce something that helps answer the question "how do I get good results out of this stuff" all in one place.&lt;/p&gt;
&lt;p&gt;I'll be developing and growing this project here on my blog as a series of chapter-shaped patterns, loosely inspired by the format popularized by &lt;a href="https://en.wikipedia.org/wiki/Design_Patterns"&gt;Design Patterns: Elements of Reusable Object-Oriented Software&lt;/a&gt; back in 1994.&lt;/p&gt;
&lt;p&gt;I published the first two chapters today:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://simonwillison.net/guides/agentic-engineering-patterns/code-is-cheap/"&gt;Writing code is cheap now&lt;/a&gt;&lt;/strong&gt; talks about the central challenge of agentic engineering: the cost to churn out initial working code has dropped to almost nothing, how does that impact our existing intuitions about how we work, both individually and as a team?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://simonwillison.net/guides/agentic-engineering-patterns/red-green-tdd/"&gt;Red/green TDD&lt;/a&gt;&lt;/strong&gt; describes how test-first development helps agents write more succinct and reliable code with minimal extra prompting.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I hope to add more chapters at a rate of 1-2 a week. I don't really know when I'll stop, there's a lot to cover!&lt;/p&gt;
&lt;h4 id="written-by-me-not-by-an-llm"&gt;Written by me, not by an LLM&lt;/h4&gt;
&lt;p&gt;I have a strong personal policy of not publishing AI-generated writing under my own name. That policy will hold true for Agentic Engineering Patterns as well. I'll be using LLMs for proofreading and fleshing out example code and all manner of other side-tasks, but the words you read here will be my own.&lt;/p&gt;
&lt;h4 id="chapters-and-guides"&gt;Chapters and Guides&lt;/h4&gt;
&lt;p&gt;Agentic Engineering Patterns isn't exactly &lt;em&gt;a book&lt;/em&gt;, but it's kind of book-shaped. I'll be publishing it on my site using a new shape of content I'm calling a &lt;em&gt;guide&lt;/em&gt;. A guide is a collection of chapters, where each chapter is effectively a blog post with a less prominent date that's designed to be updated over time, not frozen at the point of first publication.&lt;/p&gt;
&lt;p&gt;Guides and chapters are my answer to the challenge of publishing "evergreen" content on a blog. I've been trying to find a way to do this for a while now. This feels like a format that might stick.&lt;/p&gt;

&lt;p&gt;If you're interested in the implementation you can find the code in the &lt;a href="https://github.com/simonw/simonwillisonblog/blob/b9cd41a0ac4a232b2a6c90ca3fff9ae465263b02/blog/models.py#L262-L280"&gt;Guide&lt;/a&gt;, &lt;a href="https://github.com/simonw/simonwillisonblog/blob/b9cd41a0ac4a232b2a6c90ca3fff9ae465263b02/blog/models.py#L349-L405"&gt;Chapter&lt;/a&gt; and &lt;a href="https://github.com/simonw/simonwillisonblog/blob/b9cd41a0ac4a232b2a6c90ca3fff9ae465263b02/blog/models.py#L408-L423"&gt;ChapterChange&lt;/a&gt; models and the &lt;a href="https://github.com/simonw/simonwillisonblog/blob/b9cd41a0ac4a232b2a6c90ca3fff9ae465263b02/blog/views.py#L775-L923"&gt;associated Django views&lt;/a&gt;, almost all of which was written by Claude Opus 4.6 running in Claude Code for web accessed via my iPhone.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/writing"&gt;writing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/design-patterns"&gt;design-patterns&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/agentic-engineering"&gt;agentic-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/site-upgrades"&gt;site-upgrades&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="ai"/><category term="blogging"/><category term="llms"/><category term="vibe-coding"/><category term="writing"/><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="projects"/><category term="design-patterns"/><category term="agentic-engineering"/><category term="site-upgrades"/></entry><entry><title>Rodney v0.4.0</title><link href="https://simonwillison.net/2026/Feb/17/rodney/#atom-tag" rel="alternate"/><published>2026-02-17T23:02:33+00:00</published><updated>2026-02-17T23:02:33+00:00</updated><id>https://simonwillison.net/2026/Feb/17/rodney/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/rodney/releases/tag/v0.4.0"&gt;Rodney v0.4.0&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
My &lt;a href="https://github.com/simonw/rodney"&gt;Rodney&lt;/a&gt; CLI tool for browser automation attracted quite the flurry of PRs since I announced it &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#rodney-cli-browser-automation-designed-to-work-with-showboat"&gt;last week&lt;/a&gt;. Here are the release notes for the just-released v0.4.0:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Errors now use exit code 2, which means exit code 1 is just for for check failures. &lt;a href="https://github.com/simonw/rodney/pull/15"&gt;#15&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;rodney assert&lt;/code&gt; command for running JavaScript tests, exit code 1 if they fail. &lt;a href="https://github.com/simonw/rodney/issues/19"&gt;#19&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New directory-scoped sessions with &lt;code&gt;--local&lt;/code&gt;/&lt;code&gt;--global&lt;/code&gt; flags. &lt;a href="https://github.com/simonw/rodney/pull/14"&gt;#14&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;reload --hard&lt;/code&gt; and &lt;code&gt;clear-cache&lt;/code&gt; commands. &lt;a href="https://github.com/simonw/rodney/pull/17"&gt;#17&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;rodney start --show&lt;/code&gt; option to make the browser window visible. Thanks, &lt;a href="https://github.com/antocuni"&gt;Antonio Cuni&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/paull/13"&gt;#13&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;rodney connect PORT&lt;/code&gt; command to debug an already-running Chrome instance. Thanks, &lt;a href="https://github.com/pnf"&gt;Peter Fraenkel&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/pull/12"&gt;#12&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;RODNEY_HOME&lt;/code&gt; environment variable to support custom state directories. Thanks, &lt;a href="https://github.com/senko"&gt;Senko Rašić&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/pull/11"&gt;#11&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;--insecure&lt;/code&gt; flag to ignore certificate errors. Thanks, &lt;a href="https://github.com/zgolus"&gt;Jakub Zgoliński&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/pull/10"&gt;#10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Windows support: avoid &lt;code&gt;Setsid&lt;/code&gt; on Windows via build-tag helpers. Thanks, &lt;a href="https://github.com/adm1neca"&gt;adm1neca&lt;/a&gt;. &lt;a href="https://github.com/simonw/rodney/pull/18"&gt;#18&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Tests now run on &lt;code&gt;windows-latest&lt;/code&gt; and &lt;code&gt;macos-latest&lt;/code&gt; in addition to Linux.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I've been using &lt;a href="https://github.com/simonw/showboat"&gt;Showboat&lt;/a&gt; to create demos of new features - here those are for &lt;a href="https://github.com/simonw/rodney/tree/v0.4.0/notes/assert-command-demo"&gt;rodney assert&lt;/a&gt;, &lt;a href="https://github.com/simonw/rodney/tree/v0.4.0/notes/clear-cache-demo"&gt;rodney reload --hard&lt;/a&gt;, &lt;a href="https://github.com/simonw/rodney/tree/v0.4.0/notes/error-codes-demo"&gt;rodney exit codes&lt;/a&gt;, and &lt;a href="https://github.com/simonw/rodney/tree/v0.4.0/notes/local-sessions-demo"&gt;rodney start --local&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;rodney assert&lt;/code&gt; command is pretty neat: you can now Rodney to test a web app through multiple steps in a shell script that looks something like this (adapted from &lt;a href="https://github.com/simonw/rodney/blob/v0.4.0/README.md#combining-checks-in-a-shell-script"&gt;the README&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#!&lt;/span&gt;/bin/bash&lt;/span&gt;
&lt;span class="pl-c1"&gt;set&lt;/span&gt; -euo pipefail

FAIL=0

&lt;span class="pl-en"&gt;check&lt;/span&gt;() {
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-k"&gt;!&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$@&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-k"&gt;then&lt;/span&gt;
        &lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;FAIL: &lt;span class="pl-smi"&gt;$*&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
        FAIL=1
    &lt;span class="pl-k"&gt;fi&lt;/span&gt;
}

rodney start
rodney open &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://example.com&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
rodney waitstable

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Assert elements exist&lt;/span&gt;
check rodney exists &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;h1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Assert key elements are visible&lt;/span&gt;
check rodney visible &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;h1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
check rodney visible &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#main-content&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Assert JS expressions&lt;/span&gt;
check rodney assert &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;document.title&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;Example Domain&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
check rodney assert &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;document.querySelectorAll("p").length&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;2&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Assert accessibility requirements&lt;/span&gt;
check rodney ax-find --role navigation

rodney stop

&lt;span class="pl-k"&gt;if&lt;/span&gt; [ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$FAIL&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;-ne&lt;/span&gt; 0 ]&lt;span class="pl-k"&gt;;&lt;/span&gt; &lt;span class="pl-k"&gt;then&lt;/span&gt;
    &lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Some checks failed&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    &lt;span class="pl-c1"&gt;exit&lt;/span&gt; 1
&lt;span class="pl-k"&gt;fi&lt;/span&gt;
&lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;All checks passed&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/browsers"&gt;browsers&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rodney"&gt;rodney&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="annotated-release-notes"/><category term="testing"/><category term="browsers"/><category term="rodney"/></entry><entry><title>Two new Showboat tools: Chartroom and datasette-showboat</title><link href="https://simonwillison.net/2026/Feb/17/chartroom-and-datasette-showboat/#atom-tag" rel="alternate"/><published>2026-02-17T00:43:45+00:00</published><updated>2026-02-17T00:43:45+00:00</updated><id>https://simonwillison.net/2026/Feb/17/chartroom-and-datasette-showboat/#atom-tag</id><summary type="html">
    &lt;p&gt;I &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/"&gt;introduced Showboat&lt;/a&gt; a week ago - my CLI tool that helps coding agents create Markdown documents that demonstrate the code that they have created. I've been finding new ways to use it on a daily basis, and I've just released two new tools to help get the best out of the Showboat pattern. &lt;a href="https://github.com/simonw/chartroom"&gt;Chartroom&lt;/a&gt; is a CLI charting tool that works well with Showboat, and &lt;a href="https://github.com/simonw/datasette-showboat"&gt;datasette-showboat&lt;/a&gt; lets Showboat's new remote publishing feature incrementally push documents to a Datasette instance.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/17/chartroom-and-datasette-showboat/#showboat-remote-publishing"&gt;Showboat remote publishing&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/17/chartroom-and-datasette-showboat/#datasette-showboat"&gt;datasette-showboat&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/17/chartroom-and-datasette-showboat/#chartroom"&gt;Chartroom&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/17/chartroom-and-datasette-showboat/#how-i-built-chartroom"&gt;How I built Chartroom&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/17/chartroom-and-datasette-showboat/#the-burgeoning-showboat-ecosystem"&gt;The burgeoning Showboat ecosystem&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="showboat-remote-publishing"&gt;Showboat remote publishing&lt;/h4&gt;
&lt;p&gt;I normally use Showboat in Claude Code for web (see &lt;a href="https://simonwillison.net/2026/Feb/16/rodney-claude-code/"&gt;note from this morning&lt;/a&gt;). I've used it in several different projects in the past few days, each of them with a prompt that looks something like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Use "uvx showboat --help" to perform a very thorough investigation of what happens if you use the Python sqlite-chronicle and sqlite-history-json libraries against the same SQLite database table&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/simonw/research/blob/main/sqlite-chronicle-vs-history-json/demo.md"&gt;the resulting document&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Just telling Claude Code to run &lt;code&gt;uvx showboat --help&lt;/code&gt; is enough for it to learn how to use the tool - the &lt;a href="https://github.com/simonw/showboat/blob/main/help.txt"&gt;help text&lt;/a&gt; is designed to work as a sort of ad-hoc Skill document.&lt;/p&gt;
&lt;p&gt;The one catch with this approach is that I can't &lt;em&gt;see&lt;/em&gt; the new Showboat document until it's finished. I have to wait for Claude to commit the document plus embedded screenshots and push that to a branch in my GitHub repo - then I can view it through the GitHub interface.&lt;/p&gt;
&lt;p&gt;For a while I've been thinking it would be neat to have a remote web server of my own which Claude instances can submit updates to while they are working. Then this morning I realized Showboat might be the ideal mechanism to set that up...&lt;/p&gt;
&lt;p&gt;Showboat &lt;a href="https://github.com/simonw/showboat/releases/tag/v0.6.0"&gt;v0.6.0&lt;/a&gt; adds a new "remote" feature. It's almost invisible to users of the tool itself, instead being configured by an environment variable.&lt;/p&gt;
&lt;p&gt;Set a variable like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;export&lt;/span&gt; SHOWBOAT_REMOTE_URL=https://www.example.com/submit&lt;span class="pl-k"&gt;?&lt;/span&gt;token=xyz&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And every time you run a &lt;code&gt;showboat init&lt;/code&gt; or &lt;code&gt;showboat note&lt;/code&gt; or &lt;code&gt;showboat exec&lt;/code&gt; or &lt;code&gt;showboat image&lt;/code&gt; command the resulting document fragments will be POSTed to that API endpoint, in addition to the Showboat Markdown file itself being updated.&lt;/p&gt;
&lt;p&gt;There are &lt;a href="https://github.com/simonw/showboat/blob/v0.6.0/README.md#remote-document-streaming"&gt;full details in the Showboat README&lt;/a&gt; - it's a very simple API format, using regular POST form variables or a multipart form upload for the image attached to &lt;code&gt;showboat image&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id="datasette-showboat"&gt;datasette-showboat&lt;/h4&gt;
&lt;p&gt;It's simple enough to build a webapp to receive these updates from Showboat, but I needed one that I could easily deploy and would work well with the rest of my personal ecosystem.&lt;/p&gt;
&lt;p&gt;So I had Claude Code write me a Datasette plugin that could act as a Showboat remote endpoint. I actually had this building at the same time as the Showboat remote feature, a neat example of running &lt;a href="https://simonwillison.net/2025/Oct/5/parallel-coding-agents/"&gt;parallel agents&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette-showboat"&gt;datasette-showboat&lt;/a&gt;&lt;/strong&gt; is a Datasette plugin that adds a &lt;code&gt;/-/showboat&lt;/code&gt; endpoint to Datasette for viewing documents and a &lt;code&gt;/-/showboat/receive&lt;/code&gt; endpoint for receiving updates from Showboat.&lt;/p&gt;
&lt;p&gt;Here's a very quick way to try it out:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx --with datasette-showboat --prerelease=allow \
  datasette showboat.db --create \
  -s plugins.datasette-showboat.database showboat \
  -s plugins.datasette-showboat.token secret123 \
  --root --secret cookie-secret-123&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Click on the sign in as root link that shows up in the console, then navigate to &lt;a href="http://127.0.0.1:8001/-/showboat"&gt;http://127.0.0.1:8001/-/showboat&lt;/a&gt; to see the interface.&lt;/p&gt;
&lt;p&gt;Now set your environment variable to point to this instance:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;export&lt;/span&gt; SHOWBOAT_REMOTE_URL=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;http://127.0.0.1:8001/-/showboat/receive?token=secret123&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And run Showboat like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx showboat init demo.md &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Showboat Feature Demo&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Refresh that page and you should see this:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/datasette-showboat-documents.jpg" alt="Title: Showboat. Remote viewer for Showboat documents. Showboat Feature Demo 2026-02-17 00:06 · 6 chunks, UUID. To send showboat output to this server, set the SHOWBOAT_REMOTE_URL environment variable: export SHOWBOAT_REMOTE_URL=&amp;quot;http://127.0.0.1:8001/-/showboat/receive?token=your-token&amp;quot;" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Click through to the document, then start Claude Code or Codex or your agent of choice and prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Run 'uvx showboat --help' and then use showboat to add to the existing demo.md document with notes and exec and image to demonstrate the tool - fetch a placekitten for the image demo.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;code&gt;init&lt;/code&gt; command assigns a UUID and title and sends those up to Datasette.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/datasette-showboat.gif" alt="Animated demo - in the foreground a terminal window runs Claude Code, which executes various Showboat commands. In the background a Firefox window where the Showboat Feature Demo adds notes then some bash commands, then a placekitten image." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;The best part of this is that it works in Claude Code for web. Run the plugin on a server somewhere (an exercise left up to the reader - I use &lt;a href="https://fly.io/"&gt;Fly.io&lt;/a&gt; to host mine) and set that &lt;code&gt;SHOWBOAT_REMOTE_URL&lt;/code&gt; environment variable in your Claude environment, then any time you tell it to use Showboat the document it creates will be transmitted to your server and viewable in real time.&lt;/p&gt;
&lt;p&gt;I built &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#rodney-cli-browser-automation-designed-to-work-with-showboat"&gt;Rodney&lt;/a&gt;, a CLI browser automation tool, specifically to work with Showboat. It makes it easy to have a Showboat document load up web pages, interact with them via clicks or injected JavaScript and captures screenshots to embed in the Showboat document and show the effects.&lt;/p&gt;
&lt;p&gt;This is wildly useful for hacking on web interfaces using Claude Code for web, especially when coupled with the new remote publishing feature. I only got this stuff working this morning and I've already had several sessions where Claude Code has published screenshots of its work in progress, which I've then been able to provide feedback on directly in the Claude session while it's still working.&lt;/p&gt;
&lt;h3 id="chartroom"&gt;Chartroom&lt;/h3&gt;
&lt;p&gt;A few days ago I had another idea for a way to extend the Showboat ecosystem: what if Showboat documents could easily include charts?&lt;/p&gt;
&lt;p&gt;I sometimes fire up Claude Code for data analysis tasks, often telling it to download a SQLite database and then run queries against it to figure out interesting things from the data.&lt;/p&gt;
&lt;p&gt;With a simple CLI tool that produced PNG images I could have Claude use Showboat to build a document with embedded charts to help illustrate its findings.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/chartroom"&gt;Chartroom&lt;/a&gt;&lt;/strong&gt; is exactly that. It's effectively a thin wrapper around the excellent &lt;a href="https://matplotlib.org/"&gt;matplotlib&lt;/a&gt; Python library, designed to be used by coding agents to create charts that can be embedded in Showboat documents.&lt;/p&gt;
&lt;p&gt;Here's how to render a simple bar chart:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;name,value&lt;/span&gt;
&lt;span class="pl-s"&gt;Alice,42&lt;/span&gt;
&lt;span class="pl-s"&gt;Bob,28&lt;/span&gt;
&lt;span class="pl-s"&gt;Charlie,35&lt;/span&gt;
&lt;span class="pl-s"&gt;Diana,51&lt;/span&gt;
&lt;span class="pl-s"&gt;Eve,19&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;|&lt;/span&gt; uvx chartroom bar --csv \
  --title &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Sales by Person&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; --ylabel &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Sales&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/chartroom/8812afc02e1310e9eddbb56508b06005ff2c0ed5/demo/1f6851ec-2026-02-14.png"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/chartroom/8812afc02e1310e9eddbb56508b06005ff2c0ed5/demo/1f6851ec-2026-02-14.png" alt="A chart of those numbers, with a title and y-axis label" style="max-width: 100%;" /&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It can also do line charts, bar charts, scatter charts, and histograms - as seen in &lt;a href="https://github.com/simonw/chartroom/blob/0.2.1/demo/README.md"&gt;this demo document&lt;/a&gt; that was built using Showboat.&lt;/p&gt;
&lt;p&gt;Chartroom can also generate alt text. If you add &lt;code&gt;-f alt&lt;/code&gt; to the above it will output the alt text for the chart instead of the image:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;name,value&lt;/span&gt;
&lt;span class="pl-s"&gt;Alice,42&lt;/span&gt;
&lt;span class="pl-s"&gt;Bob,28&lt;/span&gt;
&lt;span class="pl-s"&gt;Charlie,35&lt;/span&gt;
&lt;span class="pl-s"&gt;Diana,51&lt;/span&gt;
&lt;span class="pl-s"&gt;Eve,19&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;|&lt;/span&gt; uvx chartroom bar --csv \
  --title &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Sales by Person&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; --ylabel &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Sales&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; -f alt&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Outputs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sales by Person. Bar chart of value by name — Alice: 42, Bob: 28, Charlie: 35, Diana: 51, Eve: 19
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or you can use &lt;code&gt;-f html&lt;/code&gt; or &lt;code&gt;-f markdown&lt;/code&gt; to get the image tag with alt text directly:&lt;/p&gt;
&lt;div class="highlight highlight-text-md"&gt;&lt;pre&gt;&lt;span class="pl-s"&gt;![&lt;/span&gt;Sales by Person. Bar chart of value by name — Alice: 42, Bob: 28, Charlie: 35, Diana: 51, Eve: 19&lt;span class="pl-s"&gt;]&lt;/span&gt;&lt;span class="pl-s"&gt;(&lt;/span&gt;&lt;span class="pl-corl"&gt;/Users/simon/chart-7.png&lt;/span&gt;&lt;span class="pl-s"&gt;)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I added support for Markdown images with alt text to Showboat in &lt;a href="https://github.com/simonw/showboat/releases/tag/v0.5.0"&gt;v0.5.0&lt;/a&gt;, to complement this feature of Chartroom.&lt;/p&gt;
&lt;p&gt;Finally, Chartroom has support for different &lt;a href="https://matplotlib.org/stable/gallery/style_sheets/style_sheets_reference.html"&gt;matplotlib styles&lt;/a&gt;. I had Claude build a Showboat document to demonstrate these all in one place - you can see that at &lt;a href="https://github.com/simonw/chartroom/blob/main/demo/styles.md"&gt;demo/styles.md&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="how-i-built-chartroom"&gt;How I built Chartroom&lt;/h4&gt;
&lt;p&gt;I started the Chartroom repository with my &lt;a href="https://github.com/simonw/click-app"&gt;click-app&lt;/a&gt; cookiecutter template, then told a fresh Claude Code for web session:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;We are building a Python CLI tool which uses matplotlib to generate a PNG image containing a chart. It will have multiple sub commands for different chart types, controlled by command line options. Everything you need to know to use it will be available in the single "chartroom --help" output.&lt;/p&gt;
&lt;p&gt;It will accept data from files or standard input as CSV or TSV or JSON, similar to how sqlite-utils accepts data - clone simonw/sqlite-utils to /tmp for reference there. Clone matplotlib/matplotlib for reference as well&lt;/p&gt;
&lt;p&gt;It will also accept data from --sql path/to/sqlite.db "select ..." which runs in read-only mode&lt;/p&gt;
&lt;p&gt;Start by asking clarifying questions - do not use the ask user tool though it is broken - and generate a spec for me to approve&lt;/p&gt;
&lt;p&gt;Once approved proceed using red/green TDD running tests with "uv run pytest"&lt;/p&gt;
&lt;p&gt;Also while building maintain a demo/README.md document using the "uvx showboat --help" tool - each time you get a new chart type working commit the tests, implementation, root level
README update and a new version of that demo/README.md document with an inline image demo of the new chart type (which should be a UUID image filename managed by the showboat image command and should be stored in the demo/ folder&lt;/p&gt;
&lt;p&gt;Make sure "uv build" runs cleanly without complaining about extra directories but also ensure dist/ and uv.lock are in gitignore&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This got most of the work done. You can see the rest &lt;a href="https://github.com/simonw/chartroom/pulls?q=is%3Apr+is%3Aclosed"&gt;in the PRs&lt;/a&gt; that followed.&lt;/p&gt;
&lt;h4 id="the-burgeoning-showboat-ecosystem"&gt;The burgeoning Showboat ecosystem&lt;/h4&gt;
&lt;p&gt;The Showboat family of tools now consists of &lt;a href="https://github.com/simonw/showboat"&gt;Showboat&lt;/a&gt; itself, &lt;a href="https://github.com/simonw/rodney"&gt;Rodney&lt;/a&gt; for browser automation, &lt;a href="https://github.com/simonw/chartroom"&gt;Chartroom&lt;/a&gt; for charting and &lt;a href="https://github.com/simonw/datasette-showboat"&gt;datasette-showboat&lt;/a&gt; for streaming remote Showboat documents to Datasette.&lt;/p&gt;
&lt;p&gt;I'm enjoying how these tools can operate together based on a very loose set of conventions. If a tool can output a path to an image Showboat can include that image in a document. Any tool that can output text can be used with Showboat.&lt;/p&gt;
&lt;p&gt;I'll almost certainly be building more tools that fit this pattern. They're very quick to knock out!&lt;/p&gt;
&lt;p&gt;The environment variable mechanism for Showboat's remote streaming is a fun hack too - so far I'm just using it to stream documents somewhere else, but it's effectively a webhook extension mechanism that could likely be used for all sorts of things I haven't thought of yet.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/showboat"&gt;showboat&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/charting"&gt;charting&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="ai"/><category term="claude-code"/><category term="llms"/><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="datasette"/><category term="generative-ai"/><category term="projects"/><category term="showboat"/><category term="charting"/></entry><entry><title>Rodney and Claude Code for Desktop</title><link href="https://simonwillison.net/2026/Feb/16/rodney-claude-code/#atom-tag" rel="alternate"/><published>2026-02-16T16:38:57+00:00</published><updated>2026-02-16T16:38:57+00:00</updated><id>https://simonwillison.net/2026/Feb/16/rodney-claude-code/#atom-tag</id><summary type="html">
    &lt;p&gt;I'm a very heavy user of &lt;a href="https://code.claude.com/docs/en/claude-code-on-the-web"&gt;Claude Code on the web&lt;/a&gt;, Anthropic's excellent but poorly named cloud version of Claude Code where everything runs in a container environment managed by them, greatly reducing the risk of anything bad happening to a computer I care about.&lt;/p&gt;
&lt;p&gt;I don't use the web interface at all (hence my dislike of the name) - I access it exclusively through their native iPhone and Mac desktop apps.&lt;/p&gt;
&lt;p&gt;Something I particularly appreciate about the desktop app is that it lets you see images that Claude is "viewing" via its &lt;code&gt;Read /path/to/image&lt;/code&gt; tool. Here's what that looks like:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a Claude Code session in Claude Desktop. Claude says: The debug page looks good - all items listed with titles and descriptions. Now let me check the nav
menu -  Analyzed menu image file - Bash uvx rodney open &amp;quot;http://localhost:8765/&amp;quot; 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; uvx rodney click &amp;quot;details.nav-menu summary&amp;quot; 2&amp;gt;&amp;amp;1 &amp;amp;% sleep 0.5 &amp;amp;&amp;amp; uvx rodney screenshot /tmp/menu.png 2&amp;gt;&amp;amp;1 Output reads: Datasette: test, Clicked, /tmp/menu.png - then it says Read /tmp/menu.png and reveals a screenshot of the Datasette interface with the nav menu open, showing only &amp;quot;Debug&amp;quot; and &amp;quot;Log out&amp;quot; options. Claude continues: The menu now has just &amp;quot;Debug&amp;quot; and “Log out&amp;quot; — much cleaner. Both pages look good. Let me clean up the server and run the remaining tests." src="https://static.simonwillison.net/static/2026/rodney-claude-desktop.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;This means you can get a visual preview of what it's working on while it's working, without waiting for it to push code to GitHub for you to try out yourself later on.&lt;/p&gt;
&lt;p&gt;The prompt I used to trigger the above screenshot was:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Run "uvx rodney --help" and then use Rodney to manually test the new pages and menu - look at screenshots from it and check you think they look OK&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I designed &lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#rodney-cli-browser-automation-designed-to-work-with-showboat"&gt;Rodney&lt;/a&gt; to have &lt;a href="https://github.com/simonw/rodney/blob/main/help.txt"&gt;--help output&lt;/a&gt; that provides everything a coding agent needs to know in order to use the tool.&lt;/p&gt;
&lt;p&gt;The Claude iPhone app doesn't display opened images yet, so I &lt;a href="https://twitter.com/simonw/status/2023432616066879606"&gt;requested it as a feature&lt;/a&gt; just now in a thread on Twitter.&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/async-coding-agents"&gt;async-coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rodney"&gt;rodney&lt;/a&gt;&lt;/p&gt;



</summary><category term="anthropic"/><category term="claude"/><category term="ai"/><category term="claude-code"/><category term="llms"/><category term="async-coding-agents"/><category term="coding-agents"/><category term="generative-ai"/><category term="projects"/><category term="ai-assisted-programming"/><category term="rodney"/></entry><entry><title>Introducing Showboat and Rodney, so agents can demo what they’ve built</title><link href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#atom-tag" rel="alternate"/><published>2026-02-10T17:45:29+00:00</published><updated>2026-02-10T17:45:29+00:00</updated><id>https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#atom-tag</id><summary type="html">
    &lt;p&gt;A key challenge working with coding agents is having them both test what they’ve built and demonstrate that software to you, their supervisor. This goes beyond automated tests - we need artifacts that show their progress and help us see exactly what the agent-produced software is able to do. I’ve just released two new tools aimed at this problem: &lt;a href="https://github.com/simonw/showboat"&gt;Showboat&lt;/a&gt; and &lt;a href="https://github.com/simonw/rodney"&gt;Rodney&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#proving-code-actually-works"&gt;Proving code actually works&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#showboat-agents-build-documents-to-demo-their-work"&gt;Showboat: Agents build documents to demo their work&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#rodney-cli-browser-automation-designed-to-work-with-showboat"&gt;Rodney: CLI browser automation designed to work with Showboat&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#test-driven-development-helps-but-we-still-need-manual-testing"&gt;Test-driven development helps, but we still need manual testing&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Feb/10/showboat-and-rodney/#i-built-both-of-these-tools-on-my-phone"&gt;I built both of these tools on my phone&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="proving-code-actually-works"&gt;Proving code actually works&lt;/h4&gt;
&lt;p&gt;I recently wrote about how the job of a software engineer isn't to write code, it's to &lt;em&gt;&lt;a href="https://simonwillison.net/2025/Dec/18/code-proven-to-work/"&gt;deliver code that works&lt;/a&gt;&lt;/em&gt;. A big part of that is proving to ourselves and to other people that the code we are responsible for behaves as expected.&lt;/p&gt;
&lt;p&gt;This becomes even more important - and challenging - as we embrace coding agents as a core part of our software development process.&lt;/p&gt;
&lt;p&gt;The more code we churn out with agents, the more valuable tools are that reduce the amount of manual QA time we need to spend.&lt;/p&gt;
&lt;p&gt;One of the most interesting things about &lt;a href="https://simonwillison.net/2026/Feb/7/software-factory/"&gt;the StrongDM software factory model&lt;/a&gt; is how they ensure that their software is well tested and delivers value despite their policy that "code must not be reviewed by humans". Part of their solution involves expensive swarms of QA agents running through "scenarios" to exercise their software. It's fascinating, but I don't want to spend thousands of dollars on QA robots if I can avoid it!&lt;/p&gt;
&lt;p&gt;I need tools that allow agents to clearly demonstrate their work to me, while minimizing the opportunities for them to cheat about what they've done.&lt;/p&gt;

&lt;h4 id="showboat-agents-build-documents-to-demo-their-work"&gt;Showboat: Agents build documents to demo their work&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/showboat"&gt;Showboat&lt;/a&gt;&lt;/strong&gt; is the tool I built to help agents demonstrate their work to me.&lt;/p&gt;
&lt;p&gt;It's a CLI tool (a Go binary, optionally &lt;a href="https://simonwillison.net/2026/Feb/4/distributing-go-binaries/"&gt;wrapped in Python&lt;/a&gt; to make it easier to install) that helps an agent construct a Markdown document demonstrating exactly what their newly developed code can do.&lt;/p&gt;
&lt;p&gt;It's not designed for humans to run, but here's how you would run it anyway:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;showboat init demo.md &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;How to use curl and jq&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
showboat note demo.md &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Here's how to use curl and jq together.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
showboat &lt;span class="pl-c1"&gt;exec&lt;/span&gt; demo.md bash &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;curl -s https://api.github.com/repos/simonw/rodney | jq .description&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
showboat note demo.md &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;And the curl logo, to demonstrate the image command:&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
showboat image demo.md &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;curl -o curl-logo.png https://curl.se/logo/curl-logo.png &amp;amp;&amp;amp; echo curl-logo.png&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's what the result looks like if you open it up in VS Code and preview the Markdown:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/curl-demo.jpg" alt="Screenshot showing a Markdown file &amp;quot;demo.md&amp;quot; side-by-side with its rendered preview. The Markdown source (left) shows: &amp;quot;# How to use curl and jq&amp;quot;, italic timestamp &amp;quot;2026-02-10T01:12:30Z&amp;quot;, prose &amp;quot;Here's how to use curl and jq together.&amp;quot;, a bash code block with &amp;quot;curl -s https://api.github.com/repos/simonw/rodney | jq .description&amp;quot;, output block showing '&amp;quot;CLI tool for interacting with the web&amp;quot;', text &amp;quot;And the curl logo, to demonstrate the image command:&amp;quot;, a bash {image} code block with &amp;quot;curl -o curl-logo.png https://curl.se/logo/curl-logo.png &amp;amp;&amp;amp; echo curl-logo.png&amp;quot;, and a Markdown image reference &amp;quot;2056e48f-2026-02-10&amp;quot;. The rendered preview (right) displays the formatted heading, timestamp, prose, styled code blocks, and the curl logo image in dark teal showing &amp;quot;curl://&amp;quot; with circuit-style design elements." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Here's that &lt;a href="https://gist.github.com/simonw/fb0b24696ed8dd91314fe41f4c453563#file-demo-md"&gt;demo.md file in a Gist&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So a sequence of &lt;code&gt;showboat init&lt;/code&gt;, &lt;code&gt;showboat note&lt;/code&gt;, &lt;code&gt;showboat exec&lt;/code&gt; and &lt;code&gt;showboat image&lt;/code&gt; commands constructs a Markdown document one section at a time, with the output of those &lt;code&gt;exec&lt;/code&gt; commands automatically added to the document directly following the commands that were run.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;image&lt;/code&gt; command is a little special - it looks for a file path to an image in the output of the command and copies that image to the current folder and references it in the file.&lt;/p&gt;
&lt;p&gt;That's basically the whole thing! There's a &lt;code&gt;pop&lt;/code&gt; command to remove the most recently added section if something goes wrong, a &lt;code&gt;verify&lt;/code&gt; command to re-run the document and check nothing has changed (I'm not entirely convinced by the design of that one) and a &lt;code&gt;extract&lt;/code&gt; command that reverse-engineers the CLI commands that were used to create the document.&lt;/p&gt;
&lt;p&gt;It's pretty simple - just 172 lines of Go.&lt;/p&gt;
&lt;p&gt;I packaged it up with my &lt;a href="https://github.com/simonw/go-to-wheel"&gt;go-to-wheel&lt;/a&gt; tool which means you can run it without even installing it first like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx showboat --help&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That &lt;code&gt;--help&lt;/code&gt; command is really important: it's designed to provide a coding agent with &lt;em&gt;everything it needs to know&lt;/em&gt; in order to use the tool. Here's &lt;a href="https://github.com/simonw/showboat/blob/main/help.txt"&gt;that help text in full&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This means you can pop open Claude Code and tell it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Run "uvx showboat --help" and then use showboat to create a demo.md document describing the feature you just built&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And that's it! The &lt;code&gt;--help&lt;/code&gt; text acts &lt;a href="https://simonwillison.net/2025/Oct/16/claude-skills/"&gt;a bit like a Skill&lt;/a&gt;. Your agent can read the help text and use every feature of Showboat to create a document that demonstrates whatever it is you need demonstrated.&lt;/p&gt;
&lt;p&gt;Here's a fun trick: if you set Claude off to build a Showboat document you can pop that open in VS Code and watch the preview pane update in real time as the agent runs through the demo. It's a bit like having your coworker talk you through their latest work in a screensharing session.&lt;/p&gt;
&lt;p&gt;And finally, some examples. Here are documents I had Claude create using Showboat to help demonstrate features I was working on in other projects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/showboat-demos/blob/main/shot-scraper/README.md"&gt;shot-scraper: A Comprehensive Demo&lt;/a&gt; runs through the full suite of features of my &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; browser automation tool, mainly to exercise the &lt;code&gt;showboat image&lt;/code&gt; command.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/sqlite-history-json/blob/main/demos/cli.md"&gt;sqlite-history-json CLI demo&lt;/a&gt; demonstrates the CLI feature I added to my new &lt;a href="https://github.com/simonw/sqlite-history-json"&gt;sqlite-history-json&lt;/a&gt; Python library.
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/sqlite-history-json/blob/main/demos/row-state-sql.md"&gt;row-state-sql CLI Demo&lt;/a&gt; shows a new &lt;code&gt;row-state-sql&lt;/code&gt; command I added to that same project.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/sqlite-history-json/blob/main/demos/change-grouping.md"&gt;Change grouping with Notes&lt;/a&gt; demonstrates another feature where groups of changes within the same transaction can have a note attached to them.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/research/blob/main/libkrun-go-cli-tool/demo.md"&gt;krunsh: Pipe Shell Commands to an Ephemeral libkrun MicroVM&lt;/a&gt; is a particularly convoluted example where I managed to get Claude Code for web to run a libkrun microVM inside a QEMU emulated Linux environment inside the Claude gVisor sandbox.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I've now used Showboat often enough that I've convinced myself of its utility.&lt;/p&gt;
&lt;p&gt;(I've also seen agents cheat! Since the demo file is Markdown the agent will sometimes edit that file directly rather than using Showboat, which could result in command outputs that don't reflect what actually happened. Here's &lt;a href="https://github.com/simonw/showboat/issues/12"&gt;an issue about that&lt;/a&gt;.)&lt;/p&gt;
&lt;h4 id="rodney-cli-browser-automation-designed-to-work-with-showboat"&gt;Rodney: CLI browser automation designed to work with Showboat&lt;/h4&gt;
&lt;p&gt;Many of the projects I work on involve web interfaces. Agents often build entirely new pages for these, and I want to see those represented in the demos.&lt;/p&gt;
&lt;p&gt;Showboat's image feature was designed to allow agents to capture screenshots as part of their demos, originally using my &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper tool&lt;/a&gt; or &lt;a href="https://www.playwright.dev"&gt;Playwright&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The Showboat format benefits from CLI utilities. I went looking for good options for managing a multi-turn browser session from a CLI and came up short, so I decided to try building something new.&lt;/p&gt;
&lt;p&gt;Claude Opus 4.6 pointed me to the &lt;a href="https://github.com/go-rod/rod"&gt;Rod&lt;/a&gt; Go library for interacting with the Chrome DevTools protocol. It's fantastic - it provides a comprehensive wrapper across basically everything you can do with automated Chrome, all in a self-contained library that compiles to a few MBs.&lt;/p&gt;
&lt;p&gt;All Rod was missing was a CLI.&lt;/p&gt;
&lt;p&gt;I built the first version &lt;a href="https://github.com/simonw/research/blob/main/go-rod-cli/README.md"&gt;as an asynchronous report prototype&lt;/a&gt;, which convinced me it was worth spinning out into its own project.&lt;/p&gt;
&lt;p&gt;I called it Rodney as a nod to the Rod library it builds on and a reference to &lt;a href="https://en.wikipedia.org/wiki/Only_Fools_and_Horses"&gt;Only Fools and Horses&lt;/a&gt; - and because the package name was available on PyPI.&lt;/p&gt;
&lt;p&gt;You can run Rodney using &lt;code&gt;uvx rodney&lt;/code&gt; or install it like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv tool install rodney&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(Or grab a Go binary &lt;a href="https://github.com/simonw/rodney/releases/"&gt;from the releases page&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;Here's a simple example session:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;rodney start &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; starts Chrome in the background&lt;/span&gt;
rodney open https://datasette.io/
rodney js &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Array.from(document.links).map(el =&amp;gt; el.href).slice(0, 5)&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
rodney click &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;a[href="/for"]&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
rodney js location.href
rodney js document.title
rodney screenshot datasette-for-page.png
rodney stop&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's what that looks like in the terminal:&lt;/p&gt;
&lt;p&gt;&lt;img alt=";~ % rodney start
Chrome started (PID 91462)
Debug URL: ws://127.0.0.1:64623/devtools/browser/cac6988e-8153-483b-80b9-1b75c611868d
~ % rodney open https://datasette.io/
Datasette: An open source multi-tool for exploring and publishing data
~ % rodney js 'Array.from(document.links).map(el =&amp;gt; el.href).slice(0, 5)'
[
&amp;quot;https://datasette.io/for&amp;quot;,
&amp;quot;https://docs.datasette.io/en/stable/&amp;quot;,
&amp;quot;https://datasette.io/tutorials&amp;quot;,
&amp;quot;https://datasette.io/examples&amp;quot;,
&amp;quot;https://datasette.io/plugins&amp;quot;
]
~ % rodney click 'a[href=&amp;quot;/for&amp;quot;]'
Clicked
~ % rodney js location.href
https://datasette.io/for
~ % rodney js document.title
Use cases for Datasette
~ % rodney screenshot datasette-for-page.png
datasette-for-page.png
~ % rodney stop
Chrome stopped" src="https://static.simonwillison.net/static/2026/rodney-demo.jpg" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;As with Showboat, this tool is not designed to be used by humans! The goal is for coding agents to be able to run &lt;code&gt;rodney --help&lt;/code&gt; and see everything they need to know to start using the tool. You can see &lt;a href="https://github.com/simonw/rodney/blob/main/help.txt"&gt;that help output&lt;/a&gt; in the GitHub repo.&lt;/p&gt;
&lt;p&gt;Here are three demonstrations of Rodney that I created using Showboat:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/showboat-demos/blob/main/rodney/README.md"&gt;Rodney's original feature set&lt;/a&gt;, including screenshots of pages and executing JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/rodney/blob/main/notes/accessibility-features/README.md"&gt;Rodney's new accessibility testing features&lt;/a&gt;, built during development of those features to show what they could do.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/showboat-demos/blob/main/datasette-database-page-accessibility-audit/README.md"&gt;Using those features to run a basic accessibility audit of a page&lt;/a&gt;. I was impressed at how well Claude Opus 4.6 responded to the prompt "Use showboat and rodney to perform an accessibility audit of &lt;a href="https://latest.datasette.io/fixtures"&gt;https://latest.datasette.io/fixtures&lt;/a&gt;" - &lt;a href="https://gisthost.github.io/?dce6b2680db4b05c04469ed8f251eb34/index.html"&gt;transcript here&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="test-driven-development-helps-but-we-still-need-manual-testing"&gt;Test-driven development helps, but we still need manual testing&lt;/h4&gt;
&lt;p&gt;After being a career-long skeptic of the test-first, maximum test coverage school of software development (I like &lt;a href="https://simonwillison.net/2022/Oct/29/the-perfect-commit/#tests"&gt;tests included&lt;/a&gt; development instead) I've recently come around to test-first processes as a way to force agents to write only the code that's necessary to solve the problem at hand.&lt;/p&gt;
&lt;p&gt;Many of my Python coding agent sessions start the same way:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Run the existing tests with "uv run pytest". Build using red/green TDD.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Telling the agents how to run the tests doubles as an indicator that tests on this project exist and matter. Agents will read existing tests before writing their own so having a clean test suite with good patterns makes it more likely they'll write good tests of their own.&lt;/p&gt;
&lt;p&gt;The frontier models all understand that "red/green TDD" means they should write the test first, run it and watch it fail and then write the code to make it pass - it's a convenient shortcut.&lt;/p&gt;
&lt;p&gt;I find this greatly increases the quality of the code and the likelihood that the agent will produce the right thing with the smallest amount of prompts to guide it.&lt;/p&gt;
&lt;p&gt;But anyone who's worked with tests will know that just because the automated tests pass doesn't mean the software actually works! That’s the motivation behind Showboat and Rodney - I never trust any feature until I’ve seen it running with my own eye.&lt;/p&gt;
&lt;p&gt;Before building Showboat I'd often add a “manual” testing step to my agent sessions, something like:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Once the tests pass, start a development server and exercise the new feature using curl&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="i-built-both-of-these-tools-on-my-phone"&gt;I built both of these tools on my phone&lt;/h4&gt;
&lt;p&gt;Both Showboat and Rodney started life as Claude Code for web projects created via the Claude iPhone app. Most of the ongoing feature work for them happened in the same way.&lt;/p&gt;
&lt;p&gt;I'm still a little startled at how much of my coding work I get done on my phone now, but I'd estimate that the majority of code I ship to GitHub these days was written for me by coding agents driven via that iPhone app.&lt;/p&gt;
&lt;p&gt;I initially designed these two tools for use in asynchronous coding agent environments like Claude Code for the web. So far that's working out really well.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/markdown"&gt;markdown&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/go"&gt;go&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/async-coding-agents"&gt;async-coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/showboat"&gt;showboat&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rodney"&gt;rodney&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="markdown"/><category term="go"/><category term="ai"/><category term="llms"/><category term="async-coding-agents"/><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="projects"/><category term="testing"/><category term="showboat"/><category term="rodney"/></entry><entry><title>Distributing Go binaries like sqlite-scanner through PyPI using go-to-wheel</title><link href="https://simonwillison.net/2026/Feb/4/distributing-go-binaries/#atom-tag" rel="alternate"/><published>2026-02-04T14:59:47+00:00</published><updated>2026-02-04T14:59:47+00:00</updated><id>https://simonwillison.net/2026/Feb/4/distributing-go-binaries/#atom-tag</id><summary type="html">
    &lt;p&gt;I've been exploring Go for building small, fast and self-contained binary applications recently. I'm enjoying how there's generally one obvious way to do things and the resulting code is boring and readable - and something that LLMs are very competent at writing. The one catch is distribution, but it turns out publishing Go binaries to PyPI means any Go binary can be just a &lt;code&gt;uvx package-name&lt;/code&gt; call away.&lt;/p&gt;
&lt;h4 id="sqlite-scanner"&gt;sqlite-scanner&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/sqlite-scanner"&gt;sqlite-scanner&lt;/a&gt; is my new Go CLI tool for scanning a filesystem for SQLite database files.&lt;/p&gt;
&lt;p&gt;It works by checking if the first 16 bytes of the file exactly match the SQLite magic number sequence &lt;code&gt;SQLite format 3\x00&lt;/code&gt;. It can search one or more folders recursively, spinning up concurrent goroutines to accelerate the scan. It streams out results as it finds them in plain text, JSON or newline-delimited JSON. It can optionally display the file sizes as well.&lt;/p&gt;
&lt;p&gt;To try it out you can download a release from the &lt;a href="https://github.com/simonw/sqlite-scanner/releases"&gt;GitHub releases&lt;/a&gt; - and then &lt;a href="https://support.apple.com/en-us/102445"&gt;jump through macOS hoops&lt;/a&gt; to execute an "unsafe" binary. Or you can clone the repo and compile it with Go. Or... you can run the binary like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx sqlite-scanner
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default this will search your current directory for SQLite databases. You can pass one or more directories as arguments:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx sqlite-scanner ~ /tmp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add &lt;code&gt;--json&lt;/code&gt; for JSON output, &lt;code&gt;--size&lt;/code&gt; to include file sizes or &lt;code&gt;--jsonl&lt;/code&gt; for newline-delimited JSON. Here's a demo:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx sqlite-scanner ~ --jsonl --size
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/sqlite-scanner-demo.gif" alt="running that command produces a sequence of JSON objects, each with a path and a size key" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;If you haven't been uv-pilled yet you can instead install &lt;code&gt;sqlite-scanner&lt;/code&gt; using &lt;code&gt;pip install sqlite-scanner&lt;/code&gt; and then run &lt;code&gt;sqlite-scanner&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To get a permanent copy with &lt;code&gt;uv&lt;/code&gt; use &lt;code&gt;uv tool install sqlite-scanner&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id="how-the-python-package-works"&gt;How the Python package works&lt;/h4&gt;
&lt;p&gt;The reason this is worth doing is that &lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;uv&lt;/code&gt; and &lt;a href="https://pypi.org/"&gt;PyPI&lt;/a&gt; will work together to identify the correct compiled binary for your operating system and architecture.&lt;/p&gt;
&lt;p&gt;This is driven by file names. If you visit &lt;a href="https://pypi.org/project/sqlite-scanner/#files"&gt;the PyPI downloads for sqlite-scanner&lt;/a&gt; you'll see the following files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-win_arm64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-win_amd64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-musllinux_1_2_x86_64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-musllinux_1_2_aarch64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-manylinux_2_17_x86_64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-manylinux_2_17_aarch64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-macosx_11_0_arm64.whl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite_scanner-0.1.1-py3-none-macosx_10_9_x86_64.whl&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When I run &lt;code&gt;pip install sqlite-scanner&lt;/code&gt; or &lt;code&gt;uvx sqlite-scanner&lt;/code&gt; on my Apple Silicon Mac laptop Python's packaging magic ensures I get that &lt;code&gt;macosx_11_0_arm64.whl&lt;/code&gt; variant.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://tools.simonwillison.net/zip-wheel-explorer?url=https%3A%2F%2Ffiles.pythonhosted.org%2Fpackages%2F88%2Fb1%2F17a716635d2733fec53ba0a8267f85bd6b6cf882c6b29301bc711fba212c%2Fsqlite_scanner-0.1.1-py3-none-macosx_11_0_arm64.whl#sqlite_scanner/__init__.py"&gt;what's in the wheel&lt;/a&gt;, which is a zip file with a &lt;code&gt;.whl&lt;/code&gt; extension.&lt;/p&gt;
&lt;p&gt;In addition to the &lt;code&gt;bin/sqlite-scanner&lt;/code&gt; the most important file is &lt;code&gt;sqlite_scanner/__init__.py&lt;/code&gt; which includes the following:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;get_binary_path&lt;/span&gt;():
    &lt;span class="pl-s"&gt;"""Return the path to the bundled binary."""&lt;/span&gt;
    &lt;span class="pl-s1"&gt;binary&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;path&lt;/span&gt;.&lt;span class="pl-c1"&gt;join&lt;/span&gt;(&lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;path&lt;/span&gt;.&lt;span class="pl-c1"&gt;dirname&lt;/span&gt;(&lt;span class="pl-s1"&gt;__file__&lt;/span&gt;), &lt;span class="pl-s"&gt;"bin"&lt;/span&gt;, &lt;span class="pl-s"&gt;"sqlite-scanner"&lt;/span&gt;)
 
    &lt;span class="pl-c"&gt;# Ensure binary is executable on Unix&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;platform&lt;/span&gt; &lt;span class="pl-c1"&gt;!=&lt;/span&gt; &lt;span class="pl-s"&gt;"win32"&lt;/span&gt;:
        &lt;span class="pl-s1"&gt;current_mode&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;stat&lt;/span&gt;(&lt;span class="pl-s1"&gt;binary&lt;/span&gt;).&lt;span class="pl-c1"&gt;st_mode&lt;/span&gt;
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-c1"&gt;not&lt;/span&gt; (&lt;span class="pl-s1"&gt;current_mode&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&lt;/span&gt; &lt;span class="pl-s1"&gt;stat&lt;/span&gt;.&lt;span class="pl-c1"&gt;S_IXUSR&lt;/span&gt;):
            &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;chmod&lt;/span&gt;(&lt;span class="pl-s1"&gt;binary&lt;/span&gt;, &lt;span class="pl-s1"&gt;current_mode&lt;/span&gt; &lt;span class="pl-c1"&gt;|&lt;/span&gt; &lt;span class="pl-s1"&gt;stat&lt;/span&gt;.&lt;span class="pl-c1"&gt;S_IXUSR&lt;/span&gt; &lt;span class="pl-c1"&gt;|&lt;/span&gt; &lt;span class="pl-s1"&gt;stat&lt;/span&gt;.&lt;span class="pl-c1"&gt;S_IXGRP&lt;/span&gt; &lt;span class="pl-c1"&gt;|&lt;/span&gt; &lt;span class="pl-s1"&gt;stat&lt;/span&gt;.&lt;span class="pl-c1"&gt;S_IXOTH&lt;/span&gt;)
 
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;binary&lt;/span&gt;
 
 
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;main&lt;/span&gt;():
    &lt;span class="pl-s"&gt;"""Execute the bundled binary."""&lt;/span&gt;
    &lt;span class="pl-s1"&gt;binary&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;get_binary_path&lt;/span&gt;()
 
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;platform&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"win32"&lt;/span&gt;:
        &lt;span class="pl-c"&gt;# On Windows, use subprocess to properly handle signals&lt;/span&gt;
        &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;exit&lt;/span&gt;(&lt;span class="pl-s1"&gt;subprocess&lt;/span&gt;.&lt;span class="pl-c1"&gt;call&lt;/span&gt;([&lt;span class="pl-s1"&gt;binary&lt;/span&gt;] &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;argv&lt;/span&gt;[&lt;span class="pl-c1"&gt;1&lt;/span&gt;:]))
    &lt;span class="pl-k"&gt;else&lt;/span&gt;:
        &lt;span class="pl-c"&gt;# On Unix, exec replaces the process&lt;/span&gt;
        &lt;span class="pl-s1"&gt;os&lt;/span&gt;.&lt;span class="pl-c1"&gt;execvp&lt;/span&gt;(&lt;span class="pl-s1"&gt;binary&lt;/span&gt;, [&lt;span class="pl-s1"&gt;binary&lt;/span&gt;] &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;sys&lt;/span&gt;.&lt;span class="pl-c1"&gt;argv&lt;/span&gt;[&lt;span class="pl-c1"&gt;1&lt;/span&gt;:])&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;main()&lt;/code&gt; method - also called from &lt;code&gt;sqlite_scanner/__main__.py&lt;/code&gt; - locates the binary and executes it when the Python package itself is executed, using the &lt;code&gt;sqlite-scanner = sqlite_scanner:main&lt;/code&gt; entry point defined in the wheel.&lt;/p&gt;
&lt;h4 id="which-means-we-can-use-it-as-a-dependency"&gt;Which means we can use it as a dependency&lt;/h4&gt;
&lt;p&gt;Using PyPI as a distribution platform for Go binaries feels a tiny bit abusive, albeit &lt;a href="https://simonwillison.net/2022/May/23/bundling-binary-tools-in-python-wheels/"&gt;there is plenty of precedent&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I’ll justify it by pointing out that this means &lt;strong&gt;we can use Go binaries as dependencies&lt;/strong&gt; for other Python packages now.&lt;/p&gt;
&lt;p&gt;That's genuinely useful! It means that any functionality which is available in a cross-platform Go binary can now be subsumed into a Python package. Python is really good at running subprocesses so this opens up a whole world of useful tricks that we can bake into our Python tools.&lt;/p&gt;
&lt;p&gt;To demonstrate this, I built &lt;a href="https://github.com/simonw/datasette-scan"&gt;datasette-scan&lt;/a&gt; - a new Datasette plugin which depends on &lt;code&gt;sqlite-scanner&lt;/code&gt; and then uses that Go binary to scan a folder for SQLite databases and attach them to a Datasette instance.&lt;/p&gt;
&lt;p&gt;Here's how to use that (without even installing anything first, thanks &lt;code&gt;uv&lt;/code&gt;) to explore any SQLite databases in your Downloads folder:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --with datasette-scan datasette scan &lt;span class="pl-k"&gt;~&lt;/span&gt;/Downloads&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;If you peek at the code you'll see it &lt;a href="https://github.com/simonw/datasette-scan/blob/1a2b6d1e6b04c8cd05f5676ff7daa877efd99f08/pyproject.toml#L14"&gt;depends on sqlite-scanner&lt;/a&gt; in &lt;code&gt;pyproject.toml&lt;/code&gt; and calls it using &lt;code&gt;subprocess.run()&lt;/code&gt; against &lt;code&gt;sqlite_scanner.get_binary_path()&lt;/code&gt; in its own &lt;a href="https://github.com/simonw/datasette-scan/blob/1a2b6d1e6b04c8cd05f5676ff7daa877efd99f08/datasette_scan/__init__.py#L38-L58"&gt;scan_directories() function&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I've been exploring this pattern for other, non-Go binaries recently - here's &lt;a href="https://github.com/simonw/tools/blob/main/python/livestream-gif.py"&gt;a recent script&lt;/a&gt; that depends on &lt;a href="https://pypi.org/project/static-ffmpeg/"&gt;static-ffmpeg&lt;/a&gt; to ensure that &lt;code&gt;ffmpeg&lt;/code&gt; is available for the script to use.&lt;/p&gt;
&lt;h4 id="building-python-wheels-from-go-packages-with-go-to-wheel"&gt;Building Python wheels from Go packages with go-to-wheel&lt;/h4&gt;
&lt;p&gt;After trying this pattern myself a couple of times I realized it would be useful to have a tool to automate the process.&lt;/p&gt;
&lt;p&gt;I first &lt;a href="https://claude.ai/share/2d9ced56-b3e8-4651-83cc-860b9b419187"&gt;brainstormed with Claude&lt;/a&gt; to check that there was no existing tool to do this. It pointed me to &lt;a href="https://www.maturin.rs/bindings.html#bin"&gt;maturin bin&lt;/a&gt; which helps distribute Rust projects using Python wheels, and &lt;a href="https://github.com/Bing-su/pip-binary-factory"&gt;pip-binary-factory&lt;/a&gt; which bundles all sorts of other projects, but did not identify anything that addressed the exact problem I was looking to solve.&lt;/p&gt;
&lt;p&gt;So I &lt;a href="https://gisthost.github.io/?41f04e4eb823b1ceb888d9a28c2280dd/index.html"&gt;had Claude Code for web build the first version&lt;/a&gt;, then refined the code locally on my laptop with the help of more Claude Code and a little bit of OpenAI Codex too, just to mix things up.&lt;/p&gt;
&lt;p&gt;The full documentation is in the &lt;a href="https://github.com/simonw/go-to-wheel"&gt;simonw/go-to-wheel&lt;/a&gt; repository. I've published that tool to PyPI so now you can run it using:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx go-to-wheel --help&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;sqlite-scanner&lt;/code&gt; package you can &lt;a href="https://pypi.org/project/sqlite-scanner/"&gt;see on PyPI&lt;/a&gt; was built using &lt;code&gt;go-to-wheel&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx go-to-wheel &lt;span class="pl-k"&gt;~&lt;/span&gt;/dev/sqlite-scanner \
  --set-version-var main.version \
  --version 0.1.1 \
  --readme README.md \
  --author &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Simon Willison&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; \
  --url https://github.com/simonw/sqlite-scanner \
  --description &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Scan directories for SQLite databases&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This created a set of wheels in the &lt;code&gt;dist/&lt;/code&gt; folder. I tested one of them like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --with dist/sqlite_scanner-0.1.1-py3-none-macosx_11_0_arm64.whl \
  sqlite-scanner --version&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;When that spat out the correct version number I was confident everything had worked as planned, so I pushed the whole set of wheels to PyPI using &lt;code&gt;twine upload&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx twine upload dist/&lt;span class="pl-k"&gt;*&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I had to paste in a PyPI API token I had saved previously.&lt;/p&gt;
&lt;h4 id="i-expect-to-use-this-pattern-a-lot"&gt;I expect to use this pattern a lot&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;sqlite-scanner&lt;/code&gt; is very clearly meant as a proof-of-concept for this wider pattern - Python is very much capable of recursively crawling a directory structure looking for files that start with a specific byte prefix on its own!&lt;/p&gt;
&lt;p&gt;That said, I think there's a &lt;em&gt;lot&lt;/em&gt; to be said for this pattern. Go is a great complement to Python - it's fast, compiles to small self-contained binaries, has excellent concurrency support and a rich ecosystem of libraries.&lt;/p&gt;
&lt;p&gt;Go is similar to Python in that it has a strong standard library. Go is particularly good for HTTP tooling - I've built several HTTP proxies in the past using Go's excellent &lt;code&gt;net/http/httputil.ReverseProxy&lt;/code&gt; handler.&lt;/p&gt;
&lt;p&gt;I've also been experimenting with &lt;a href="https://github.com/wazero/wazero"&gt;wazero&lt;/a&gt;, Go's robust and mature zero dependency WebAssembly runtime as part of my ongoing quest for the ideal sandbox for running untrusted code. &lt;a href="https://github.com/simonw/research/tree/main/wasm-repl-cli"&gt;Here's my latest experiment&lt;/a&gt; with that library.&lt;/p&gt;
&lt;p&gt;Being able to seamlessly integrate Go binaries into Python projects without the end user having to think about Go at all - they &lt;code&gt;pip install&lt;/code&gt; and everything Just Works - feels like a valuable addition to my toolbox.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/go"&gt;go&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/packaging"&gt;packaging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="uv"/><category term="go"/><category term="pypi"/><category term="packaging"/><category term="ai-assisted-programming"/><category term="python"/><category term="datasette"/><category term="projects"/><category term="sqlite"/></entry><entry><title>Datasette 1.0a24</title><link href="https://simonwillison.net/2026/Jan/29/datasette-10a24/#atom-tag" rel="alternate"/><published>2026-01-29T17:21:51+00:00</published><updated>2026-01-29T17:21:51+00:00</updated><id>https://simonwillison.net/2026/Jan/29/datasette-10a24/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a24-2026-01-29"&gt;Datasette 1.0a24&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New Datasette alpha this morning. Key new features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Datasette's &lt;code&gt;Request&lt;/code&gt; object can now handle &lt;code&gt;multipart/form-data&lt;/code&gt; file uploads via the new &lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-formdata"&gt;await request.form(files=True)&lt;/a&gt;  method. I plan to use this for a &lt;code&gt;datasette-files&lt;/code&gt; plugin to support attaching files to rows of data.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://docs.datasette.io/en/latest/contributing.html#setting-up-a-development-environment"&gt;recommended development environment&lt;/a&gt; for hacking on Datasette itself now uses &lt;a href="https://github.com/astral-sh/uv"&gt;uv&lt;/a&gt;. Crucially, you can clone Datasette and run &lt;code&gt;uv run pytest&lt;/code&gt; to run the tests without needing to manually create a virtual environment or install dependencies first, thanks to the &lt;a href="https://til.simonwillison.net/uv/dependency-groups"&gt;dev dependency group pattern&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A new &lt;code&gt;?_extra=render_cell&lt;/code&gt; parameter for both table and row JSON pages to return the results of executing the &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#render-cell-row-value-column-table-database-datasette-request"&gt;render_cell() plugin hook&lt;/a&gt;. This should unlock new JavaScript UI features in the future.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;More details &lt;a href="https://docs.datasette.io/en/latest/changelog.html#a24-2026-01-29"&gt;in the release notes&lt;/a&gt;. I also invested a bunch of work in eliminating flaky tests that were intermittently failing in CI - I &lt;em&gt;think&lt;/em&gt; those are all handled now.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="datasette"/><category term="python"/><category term="uv"/><category term="annotated-release-notes"/></entry><entry><title>Introducing gisthost.github.io</title><link href="https://simonwillison.net/2026/Jan/1/gisthost/#atom-tag" rel="alternate"/><published>2026-01-01T22:12:20+00:00</published><updated>2026-01-01T22:12:20+00:00</updated><id>https://simonwillison.net/2026/Jan/1/gisthost/#atom-tag</id><summary type="html">
    &lt;p&gt;I am a huge fan of &lt;a href="https://gistpreview.github.io/"&gt;gistpreview.github.io&lt;/a&gt;, the site by Leon Huang that lets you append &lt;code&gt;?GIST_id&lt;/code&gt; to see a browser-rendered version of an HTML page that you have saved to a Gist. The last commit was ten years ago and I needed a couple of small changes so I've forked it and deployed an updated version at &lt;a href="https://gisthost.github.io/"&gt;gisthost.github.io&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="some-background-on-gistpreview"&gt;Some background on gistpreview&lt;/h4&gt;
&lt;p&gt;The genius thing about &lt;code&gt;gistpreview.github.io&lt;/code&gt; is that it's a core piece of GitHub infrastructure, hosted and cost-covered entirely by GitHub, that wasn't built with any involvement from GitHub at all.&lt;/p&gt;
&lt;p&gt;To understand how it works we need to first talk about Gists.&lt;/p&gt;
&lt;p&gt;Any file hosted in a &lt;a href="https://gist.github.com/"&gt;GitHub Gist&lt;/a&gt; can be accessed via a direct URL that looks like this:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;https://gist.githubusercontent.com/simonw/d168778e8e62f65886000f3f314d63e3/raw/79e58f90821aeb8b538116066311e7ca30c870c9/index.html&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;That URL is served with a few key HTTP headers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These ensure that every file is treated by browsers as plain text, so HTML file will not be rendered even by older browsers that attempt to guess the content type based on the content.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Via: 1.1 varnish
Cache-Control: max-age=300
X-Served-By: cache-sjc1000085-SJC
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These confirm that the file is sever via GitHub's caching CDN, which means I don't feel guilty about linking to them for potentially high traffic scenarios.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Access-Control-Allow-Origin: *
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is my favorite HTTP header! It means I can hit these files with a &lt;code&gt;fetch()&lt;/code&gt; call from any domain on the internet, which is fantastic for building &lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/"&gt;HTML tools&lt;/a&gt; that do useful things with content hosted in a Gist.&lt;/p&gt;
&lt;p&gt;The one big catch is that Content-Type header. It means you can't use a Gist to serve HTML files that people can view.&lt;/p&gt;
&lt;p&gt;That's where &lt;code&gt;gistpreview&lt;/code&gt; comes in. The &lt;code&gt;gistpreview.github.io&lt;/code&gt; site belongs to the dedicated &lt;a href="https://github.com/gistpreview"&gt;gistpreview&lt;/a&gt; GitHub organization, and is served out of the &lt;a href="https://github.com/gistpreview/gistpreview.github.io"&gt;github.com/gistpreview/gistpreview.github.io&lt;/a&gt; repository by GitHub Pages.&lt;/p&gt;
&lt;p&gt;It's not much code. The key functionality is this snippet of JavaScript from &lt;a href="https://github.com/gistpreview/gistpreview.github.io/blob/master/main.js"&gt;main.js&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&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;'https://api.github.com/gists/'&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;gistId&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;then&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-s1"&gt;res&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;return&lt;/span&gt; &lt;span class="pl-s1"&gt;res&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;json&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;then&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-s1"&gt;body&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;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;res&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;status&lt;/span&gt; &lt;span class="pl-c1"&gt;===&lt;/span&gt; &lt;span class="pl-c1"&gt;200&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;return&lt;/span&gt; &lt;span class="pl-s1"&gt;body&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;console&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;res&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;body&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-c"&gt;// debug&lt;/span&gt;
    &lt;span class="pl-k"&gt;throw&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Error&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'Gist &amp;lt;strong&amp;gt;'&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;gistId&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;lt;/strong&amp;gt;, '&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;body&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;message&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-cce"&gt;\(&lt;/span&gt;.&lt;span class="pl-c1"&gt;*&lt;/span&gt;&lt;span class="pl-cce"&gt;\)&lt;/span&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;,&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-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;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;then&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-s1"&gt;info&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;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;fileName&lt;/span&gt; &lt;span class="pl-c1"&gt;===&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-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;file&lt;/span&gt; &lt;span class="pl-k"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;info&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;files&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-c"&gt;// index.html or the first file&lt;/span&gt;
      &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;fileName&lt;/span&gt; &lt;span class="pl-c1"&gt;===&lt;/span&gt; &lt;span class="pl-s"&gt;''&lt;/span&gt; &lt;span class="pl-c1"&gt;||&lt;/span&gt; &lt;span class="pl-s1"&gt;file&lt;/span&gt; &lt;span class="pl-c1"&gt;===&lt;/span&gt; &lt;span class="pl-s"&gt;'index.html'&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-s1"&gt;fileName&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;file&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-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;info&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;files&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;hasOwnProperty&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;fileName&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-c1"&gt;===&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-k"&gt;throw&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Error&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'File &amp;lt;strong&amp;gt;'&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;fileName&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;'&amp;lt;/strong&amp;gt; is not exist'&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-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;content&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;info&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;files&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;fileName&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;content&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;write&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;content&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 chain of promises fetches the Gist content from the GitHub API, finds the section of that JSON corresponding to the requested file name and then outputs it to the page like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&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;write&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;content&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 is smart. Injecting the content using &lt;code&gt;document.body.innerHTML = content&lt;/code&gt; would fail to execute inline scripts. Using &lt;code&gt;document.write()&lt;/code&gt; causes the browser to treat the HTML as if it was directly part of the parent page.&lt;/p&gt;
&lt;p&gt;That's pretty much the whole trick! Read the Gist ID from the query string, fetch the content via the JSON API and &lt;code&gt;document.write()&lt;/code&gt; it into the page.&lt;/p&gt;
&lt;p&gt;Here's a demo:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gistpreview.github.io/?d168778e8e62f65886000f3f314d63e3"&gt;https://gistpreview.github.io/?d168778e8e62f65886000f3f314d63e3&lt;/a&gt;&lt;/p&gt;
&lt;h4 id="fixes-for-gisthost-github-io"&gt;Fixes for gisthost.github.io&lt;/h4&gt;
&lt;p&gt;I forked &lt;code&gt;gistpreview&lt;/code&gt; to add two new features:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A workaround for Substack mangling the URLs&lt;/li&gt;
&lt;li&gt;The ability to serve larger files that get truncated in the JSON API&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I also removed some dependencies (jQuery and Bootstrap and an old &lt;code&gt;fetch()&lt;/code&gt; polyfill) and inlined the JavaScript into &lt;a href="https://github.com/gisthost/gisthost.github.io/blob/main/index.html"&gt;a single index.html file&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The Substack issue was small but frustrating. If you email out a link to a &lt;code&gt;gistpreview&lt;/code&gt; page via Substack it modifies the URL to look like this:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gistpreview.github.io/?f40971b693024fbe984a68b73cc283d2=&amp;amp;utm_source=substack&amp;amp;utm_medium=email"&gt;https://gistpreview.github.io/?f40971b693024fbe984a68b73cc283d2=&amp;amp;utm_source=substack&amp;amp;utm_medium=email&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This breaks &lt;code&gt;gistpreview&lt;/code&gt; because it treats &lt;code&gt;f40971b693024fbe984a68b73cc283d2=&amp;amp;utm_source...&lt;/code&gt; as the Gist ID.&lt;/p&gt;
&lt;p&gt;The fix is to read everything up to that equals sign. I &lt;a href="https://github.com/gistpreview/gistpreview.github.io/pull/7"&gt;submitted a PR&lt;/a&gt; for that back in November.&lt;/p&gt;
&lt;p&gt;The second issue around truncated files was &lt;a href="https://github.com/simonw/claude-code-transcripts/issues/26#issuecomment-3699668871"&gt;reported against my claude-code-transcripts project&lt;/a&gt; a few days ago.&lt;/p&gt;
&lt;p&gt;That project provides a CLI tool for exporting HTML rendered versions of Claude Code sessions. It includes a &lt;code&gt;--gist&lt;/code&gt; option which uses the &lt;code&gt;gh&lt;/code&gt; CLI tool to publish the resulting HTML to a Gist and returns a gistpreview URL that the user can share.&lt;/p&gt;
&lt;p&gt;These exports can get pretty big, and some of the resulting HTML was past the size limit of what comes back from the Gist API.&lt;/p&gt;
&lt;p&gt;As of &lt;a href="https://github.com/simonw/claude-code-transcripts/releases/tag/0.5"&gt;claude-code-transcripts 0.5&lt;/a&gt; the &lt;code&gt;--gist&lt;/code&gt; option now publishes to &lt;a href="https://gisthost.github.io/"&gt;gisthost.github.io&lt;/a&gt; instead, fixing both bugs.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gisthost.github.io/?02ced545666128ce4206103df6185536"&gt;the Claude Code transcript&lt;/a&gt; that refactored Gist Host to remove those dependencies, which I published to Gist Host using the following command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx claude-code-transcripts web --gist
&lt;/code&gt;&lt;/pre&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/http"&gt;http&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="ai-assisted-programming"/><category term="javascript"/><category term="projects"/><category term="cors"/><category term="github"/><category term="http"/></entry><entry><title>shot-scraper 1.9</title><link href="https://simonwillison.net/2025/Dec/29/shot-scraper/#atom-tag" rel="alternate"/><published>2025-12-29T22:33:13+00:00</published><updated>2025-12-29T22:33:13+00:00</updated><id>https://simonwillison.net/2025/Dec/29/shot-scraper/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.9"&gt;shot-scraper 1.9&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New release of my &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; CLI tool for taking screenshots and scraping websites with JavaScript from the terminal.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;shot-scraper har&lt;/code&gt; command has a new &lt;code&gt;-x/--extract&lt;/code&gt; option which extracts all of the resources loaded by the page out to a set of files. This location can be controlled by the &lt;code&gt;-o dir/&lt;/code&gt; option. &lt;a href="https://github.com/simonw/shot-scraper/issues/184"&gt;#184&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Fixed the &lt;code&gt;shot-scraper accessibility&lt;/code&gt; command for compatibility with the latest Playwright. &lt;a href="https://github.com/simonw/shot-scraper/issues/185"&gt;#185&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The new &lt;code&gt;shot-scraper har -x https://simonwillison.net/&lt;/code&gt; command is really neat. The inspiration was &lt;a href="https://simonwillison.net/2025/Dec/26/slop-acts-of-kindness/#digital-forensics-with-shot-scraper-har"&gt;the digital forensics expedition&lt;/a&gt; I went on to figure out why Rob Pike got spammed. You can now perform a version of that investigation like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd /tmp
shot-scraper har --wait 10000 'https://theaidigest.org/village?day=265' -x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then dig around in the resulting JSON files in the &lt;code&gt;/tmp/theaidigest-org-village&lt;/code&gt; folder.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="shot-scraper"/><category term="annotated-release-notes"/></entry><entry><title>A new way to extract detailed transcripts from Claude Code</title><link href="https://simonwillison.net/2025/Dec/25/claude-code-transcripts/#atom-tag" rel="alternate"/><published>2025-12-25T23:52:17+00:00</published><updated>2025-12-25T23:52:17+00:00</updated><id>https://simonwillison.net/2025/Dec/25/claude-code-transcripts/#atom-tag</id><summary type="html">
    &lt;p&gt;I've released &lt;a href="https://github.com/simonw/claude-code-transcripts"&gt;claude-code-transcripts&lt;/a&gt;, a new Python CLI tool for converting &lt;a href="https://claude.ai/code"&gt;Claude Code&lt;/a&gt; transcripts to detailed HTML pages that provide a better interface for understanding what Claude Code has done than even Claude Code itself. The resulting transcripts are also designed to be shared, using any static HTML hosting or even via GitHub Gists.&lt;/p&gt;
&lt;p&gt;Here's the quick start, with no installation required if you already have &lt;a href="https://docs.astral.sh/uv/"&gt;uv&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx claude-code-transcripts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(Or you could &lt;code&gt;uv tool install claude-code-transcripts&lt;/code&gt; or &lt;code&gt;pip install claude-code-transcripts&lt;/code&gt; first, if you like.)&lt;/p&gt;
&lt;p&gt;This will bring up a list of your local Claude Code sessions. Hit up and down to select one, then hit &lt;code&gt;&amp;lt;enter&amp;gt;&lt;/code&gt;. The tool will create a new folder with an &lt;code&gt;index.html&lt;/code&gt; file showing a summary of the transcript and one or more &lt;code&gt;page_x.html&lt;/code&gt; files with the full details of everything that happened.&lt;/p&gt;
&lt;p&gt;Visit &lt;a href="https://static.simonwillison.net/static/2025/claude-code-microjs/index.html"&gt;this example page&lt;/a&gt; to see a lengthy (12 page) transcript produced using this tool.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/claude-code-transcripts-example.jpg" alt="Screenshot of a claude code transcript spanning 12 pages - the first page shows a summary starting with the first user prompt to clone bellard/quickjs to /tmp" style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;If you have the &lt;a href="https://cli.github.com/"&gt;gh CLI tool&lt;/a&gt; installed and authenticated you can add the &lt;code&gt;--gist&lt;/code&gt; option - the transcript you select will then be automatically shared to a new Gist and a link provided to &lt;code&gt;gistpreview.github.io&lt;/code&gt; to view it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;claude-code-transcripts&lt;/code&gt; can also fetch sessions from Claude Code for web. I reverse-engineered the private API for this (so I hope it continues to work), but right now you can run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx claude-code-transcripts web --gist
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then select a Claude Code for web session and have that converted to HTML and published as a Gist as well.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/simonw/claude-code-transcripts/blob/main/README.md"&gt;claude-code-transcripts README&lt;/a&gt; has full details of the other options provided by the tool.&lt;/p&gt;
&lt;h4 id="why-i-built-this"&gt;Why I built this&lt;/h4&gt;
&lt;p&gt;These days I'm writing significantly more code via Claude Code than by typing text into a text editor myself. I'm actually getting more coding work done &lt;em&gt;on my phone&lt;/em&gt; than on my laptop, thanks to the Claude Code interface in Anthropic's Claude iPhone app.&lt;/p&gt;
&lt;p&gt;Being able to have an idea on a walk and turn that into working, tested and documented code from a couple of prompts on my phone is a truly science fiction way of working. I'm enjoying it a lot.&lt;/p&gt;
&lt;p&gt;There's one problem: the actual &lt;em&gt;work&lt;/em&gt; that I do is now increasingly represented by these Claude conversations. Those transcripts capture extremely important context about my projects: what I asked for, what Claude suggested, decisions I made, and Claude's own justification for the decisions it made while implementing a feature.&lt;/p&gt;
&lt;p&gt;I value these transcripts a lot! They help me figure out which prompting strategies work, and they provide an invaluable record of the decisions that went into building features.&lt;/p&gt;
&lt;p&gt;In the pre-LLM era I relied on issues and issue comments to record all of this extra project context, but now those conversations are happening in the Claude Code interface instead.&lt;/p&gt;
&lt;p&gt;I've made several past attempts at solving this problem. The first was pasting Claude Code terminal sessions into a shareable format - I &lt;a href="https://simonwillison.net/2025/Oct/23/claude-code-for-web-video/"&gt;built a custom tool for that&lt;/a&gt; (called &lt;a href="https://tools.simonwillison.net/terminal-to-html/"&gt;terminal-to-html&lt;/a&gt; and I've used it a lot, but it misses a bunch of detail - including the default-invisible thinking traces that Claude Code generates while working on a task.&lt;/p&gt;
&lt;p&gt;I've also built &lt;a href="https://tools.simonwillison.net/colophon#claude-code-timeline.html"&gt;claude-code-timeline&lt;/a&gt; and &lt;a href="https://tools.simonwillison.net/colophon#codex-timeline.html"&gt;codex-timeline&lt;/a&gt; as HTML tool viewers for JSON transcripts from both Claude Code and Codex. Those work pretty well, but still are not quite as human-friendly as I'd like.&lt;/p&gt;
&lt;p&gt;An even bigger problem is Claude Code for web - Anthropic's asynchronous coding agent, which is the thing I've been using from my phone. Getting transcripts out of that is even harder! I've been synchronizing them down to my laptop just so I can copy and paste from the terminal but that's a pretty inelegant solution.&lt;/p&gt;
&lt;h4 id="how-i-built-claude-code-transcripts"&gt;How I built claude-code-transcripts&lt;/h4&gt;
&lt;p&gt;You won't be surprised to hear that every inch of this new tool was built using Claude.&lt;/p&gt;
&lt;p&gt;You can browse &lt;a href="https://github.com/simonw/claude-code-transcripts/commits/main/"&gt;the commit log&lt;/a&gt; to find links to the transcripts for each commit, many of them published using the tool itself.&lt;/p&gt;
&lt;p&gt;Here are some recent examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/claude-code-transcripts/commit/c80b1dee9429637318f4fae3e5d733ae5c05ab2c"&gt;c80b1dee&lt;/a&gt; Rename tool from claude-code-publish to claude-code-transcripts - &lt;a href="https://gistpreview.github.io/?814530b3a70af8408f3bb8ca10f70d57/index.html"&gt;transcript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/claude-code-transcripts/commit/ad3e9a05058c583bf7327421f727ba08c15aa8a0"&gt;ad3e9a05&lt;/a&gt; Update README for latest changes - &lt;a href="https://gistpreview.github.io/?9b3fe747343d32c95a8565ef1f8b6e11/index.html"&gt;transcript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/claude-code-transcripts/commit/e1013c54a601e79e62a9bf204c5a94acc8845c5f"&gt;e1013c54&lt;/a&gt; Add autouse fixture to mock webbrowser.open in tests - &lt;a href="https://gistpreview.github.io/?1671b49de273d80280ab2ceab690db8c/index.html"&gt;transcript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/claude-code-transcripts/commit/77512e5d6905ee8ba678af0e30bcee2dccb549f3"&gt;77512e5d&lt;/a&gt; Add Jinja2 templates for HTML generation (#2) - &lt;a href="https://gistpreview.github.io/?ffc01d1c04e47ed7934a58ae04a066d1/index.html"&gt;transcript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/claude-code-transcripts/commit/b3e038adeac56e81d7c7558f0a7d39a8d44d9534"&gt;b3e038ad&lt;/a&gt; Add version flag to CLI (#1) - &lt;a href="https://gistpreview.github.io/?7bdf1535f7bf897fb475be6ff5da2e1c/index.html"&gt;transcript&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I had Claude use the following dependencies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/click/"&gt;click&lt;/a&gt; and &lt;a href="https://pypi.org/project/click-default-group/"&gt;click-default-group&lt;/a&gt; for building the CLI&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/Jinja2/"&gt;Jinja2&lt;/a&gt; for HTML templating - a late refactoring, the initial system used Python string concatenation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/httpx/"&gt;httpx&lt;/a&gt; for making HTTP requests&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/Markdown/"&gt;markdown&lt;/a&gt; for converting Markdown to HTML&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/questionary/"&gt;questionary&lt;/a&gt; - new to me, suggested by Claude - to implement the interactive list selection UI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And for development dependencies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/pytest/"&gt;pytest&lt;/a&gt; - always&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/pytest-httpx/"&gt;pytest-httpx&lt;/a&gt; to mock HTTP requests in tests&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/syrupy/"&gt;syrupy&lt;/a&gt; for snapshot testing - with a tool like this that generates complex HTML snapshot testing is a great way to keep the tests robust and simple. Here's &lt;a href="https://github.com/simonw/claude-code-transcripts/tree/main/tests/__snapshots__/test_generate_html"&gt;that collection of snapshots&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The one bit that wasn't done with Claude Code was reverse engineering Claude Code itself to figure out how to retrieve session JSON from Claude Code for web.&lt;/p&gt;
&lt;p&gt;I know Claude Code can reverse engineer itself, but it felt a bit more subversive to have OpenAI Codex CLI do it instead. &lt;a href="https://gistpreview.github.io/?e4159193cd2468060d91289b5ccdece3"&gt;Here's that transcript&lt;/a&gt; - I had Codex use &lt;code&gt;npx prettier&lt;/code&gt; to pretty-print the obfuscated Claude Code JavaScript, then asked it to dig out the API and authentication details.&lt;/p&gt;
&lt;p&gt;Codex came up with this &lt;em&gt;beautiful&lt;/em&gt; &lt;code&gt;curl&lt;/code&gt; command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -sS -f \
    -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;security find-generic-password -a &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$USER&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; -w -s &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Claude Code-credentials&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;|&lt;/span&gt; jq-r .claudeAiOauth.accessToken&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;  \
    -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;anthropic-version: 2023-06-01&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
    -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Content-Type: application/json&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
    -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;x-organization-uuid: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;jq -r &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;.oauthAccount.organizationUuid&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;~&lt;/span&gt;/.claude.json&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&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;https://api.anthropic.com/v1/sessions&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The really neat trick there is the way it extracts Claude Code's OAuth token from the macOS Keychain using the &lt;code&gt;security find-generic-password&lt;/code&gt; command. I ended up using that trick in &lt;code&gt;claude-code-transcripts&lt;/code&gt; itself!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="anthropic"/><category term="claude"/><category term="ai"/><category term="claude-code"/><category term="llms"/><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="generative-ai"/><category term="projects"/></entry><entry><title>uv-init-demos</title><link href="https://simonwillison.net/2025/Dec/24/uv-init-demos/#atom-tag" rel="alternate"/><published>2025-12-24T22:05:23+00:00</published><updated>2025-12-24T22:05:23+00:00</updated><id>https://simonwillison.net/2025/Dec/24/uv-init-demos/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/uv-init-demos"&gt;uv-init-demos&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;code&gt;uv&lt;/code&gt; has a useful &lt;code&gt;uv init&lt;/code&gt; command for setting up new Python projects, but it comes with a bunch of different options like &lt;code&gt;--app&lt;/code&gt; and &lt;code&gt;--package&lt;/code&gt; and &lt;code&gt;--lib&lt;/code&gt; and I wasn't sure how they differed.&lt;/p&gt;
&lt;p&gt;So I created this GitHub repository which demonstrates all of those options, generated using this &lt;a href="https://github.com/simonw/uv-init-demos/blob/main/update-projects.sh"&gt;update-projects.sh&lt;/a&gt; script (&lt;a href="https://gistpreview.github.io/?9cff2d3b24ba3d5f423b34abc57aec13"&gt;thanks, Claude&lt;/a&gt;) which will run on a schedule via GitHub Actions to capture any changes made by future releases of &lt;code&gt;uv&lt;/code&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git-scraping"&gt;git-scraping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;&lt;/p&gt;



</summary><category term="uv"/><category term="git-scraping"/><category term="python"/><category term="projects"/><category term="github-actions"/></entry><entry><title>s3-credentials 0.17</title><link href="https://simonwillison.net/2025/Dec/16/s3-credentials/#atom-tag" rel="alternate"/><published>2025-12-16T23:40:31+00:00</published><updated>2025-12-16T23:40:31+00:00</updated><id>https://simonwillison.net/2025/Dec/16/s3-credentials/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/s3-credentials/releases/tag/0.17"&gt;s3-credentials 0.17&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New release of my &lt;a href="https://s3-credentials.readthedocs.io/"&gt;s3-credentials&lt;/a&gt; CLI tool for managing credentials needed to access just one S3 bucket. Here are the release notes in full:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New commands &lt;code&gt;get-bucket-policy&lt;/code&gt; and &lt;code&gt;set-bucket-policy&lt;/code&gt;. &lt;a href="https://github.com/simonw/s3-credentials/issues/91"&gt;#91&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New commands &lt;code&gt;get-public-access-block&lt;/code&gt; and &lt;code&gt;set-public-access-block&lt;/code&gt;. &lt;a href="https://github.com/simonw/s3-credentials/issues/92"&gt;#92&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;localserver&lt;/code&gt; command for starting a web server that makes time limited credentials accessible via a JSON API. &lt;a href="https://github.com/simonw/s3-credentials/pull/93"&gt;#93&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;That &lt;code&gt;s3-credentials localserver&lt;/code&gt; command (&lt;a href="https://s3-credentials.readthedocs.io/en/stable/localserver.html"&gt;documented here&lt;/a&gt;) is a little obscure, but I found myself wanting something like that to help me test out a new feature I'm building to help create temporary Litestream credentials using Amazon STS.&lt;/p&gt;
&lt;p&gt;Most of that new feature was &lt;a href="https://gistpreview.github.io/?500add71f397874ebadb8e04e8a33b53"&gt;built by Claude Code&lt;/a&gt; from the following starting prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Add a feature s3-credentials localserver which starts a localhost weberver running (using the Python standard library stuff) on port 8094 by default but -p/--port can set a different port and otherwise takes an option that names a bucket and then takes the same options for read--write/read-only etc as other commands. It also takes a required --refresh-interval option which can be set as 5m or 10h or 30s. All this thing does is reply on / to a GET request with the IAM expiring credentials that allow access to that bucket with that policy for that specified amount of time. It caches internally the credentials it generates and will return the exact same data up until they expire (it also tracks expected expiry time) after which it will generate new credentials (avoiding dog pile effects if multiple requests ask at the same time) and return and cache those instead.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/s3-credentials"&gt;s3-credentials&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/s3"&gt;s3&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/aws"&gt;aws&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-engineering"&gt;prompt-engineering&lt;/a&gt;&lt;/p&gt;



</summary><category term="annotated-release-notes"/><category term="s3-credentials"/><category term="ai"/><category term="claude-code"/><category term="llms"/><category term="coding-agents"/><category term="generative-ai"/><category term="projects"/><category term="s3"/><category term="aws"/><category term="prompt-engineering"/></entry><entry><title>LLM 0.28</title><link href="https://simonwillison.net/2025/Dec/12/llm-028/#atom-tag" rel="alternate"/><published>2025-12-12T20:20:14+00:00</published><updated>2025-12-12T20:20:14+00:00</updated><id>https://simonwillison.net/2025/Dec/12/llm-028/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-28"&gt;LLM 0.28&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I released a new version of my &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; Python library and CLI tool for interacting with Large Language Models. Highlights from the release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New OpenAI models: &lt;code&gt;gpt-5.1&lt;/code&gt;, &lt;code&gt;gpt-5.1-chat-latest&lt;/code&gt;, &lt;code&gt;gpt-5.2&lt;/code&gt; and &lt;code&gt;gpt-5.2-chat-latest&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1300"&gt;#1300&lt;/a&gt;, &lt;a href="https://github.com/simonw/llm/issues/1317"&gt;#1317&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;When fetching URLs as fragments using &lt;code&gt;llm -f URL&lt;/code&gt;, the request now includes a custom user-agent header: &lt;code&gt;llm/VERSION (https://llm.datasette.io/)&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1309"&gt;#1309&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Fixed a bug where fragments were not correctly registered with their source when using &lt;code&gt;llm chat&lt;/code&gt;. Thanks, &lt;a href="https://github.com/grota"&gt;Giuseppe Rota&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/1316"&gt;#1316&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Fixed some file descriptor leak warnings. Thanks, &lt;a href="https://github.com/eedeebee"&gt;Eric Bloch&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1313"&gt;#1313&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Type annotations for the OpenAI Chat, AsyncChat and Completion &lt;code&gt;execute()&lt;/code&gt; methods. Thanks, &lt;a href="https://github.com/ar-jan"&gt;Arjan Mossel&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/1315"&gt;#1315&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;The project now uses &lt;code&gt;uv&lt;/code&gt; and dependency groups for development. See the updated &lt;a href="https://llm.datasette.io/en/stable/contributing.html"&gt;contributing documentation&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1318"&gt;#1318&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;That last bullet point about &lt;code&gt;uv&lt;/code&gt; relates to the dependency groups pattern I &lt;a href="https://til.simonwillison.net/uv/dependency-groups"&gt;wrote about in a recent TIL&lt;/a&gt;. I'm currently working through applying it to my other projects - the net result is that running the test suite is as simple as doing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/simonw/llm
cd llm
uv run pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The new &lt;code&gt;dev&lt;/code&gt; dependency group &lt;a href="https://github.com/simonw/llm/blob/0.28/pyproject.toml#L44-L69"&gt;defined in pyproject.toml&lt;/a&gt; is automatically installed by &lt;code&gt;uv run&lt;/code&gt; in a new virtual environment which means everything needed to run &lt;code&gt;pytest&lt;/code&gt; is available without needing to add any extra commands.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;&lt;/p&gt;



</summary><category term="llm"/><category term="uv"/><category term="annotated-release-notes"/><category term="ai"/><category term="llms"/><category term="python"/><category term="generative-ai"/><category term="projects"/></entry><entry><title>Useful patterns for building HTML tools</title><link href="https://simonwillison.net/2025/Dec/10/html-tools/#atom-tag" rel="alternate"/><published>2025-12-10T21:00:59+00:00</published><updated>2025-12-10T21:00:59+00:00</updated><id>https://simonwillison.net/2025/Dec/10/html-tools/#atom-tag</id><summary type="html">
    &lt;p&gt;I've started using the term &lt;strong&gt;HTML tools&lt;/strong&gt; to refer to HTML applications that I've been building which combine HTML, JavaScript, and CSS in a single file and use them to provide useful functionality. I have built &lt;a href="https://tools.simonwillison.net/"&gt;over 150 of these&lt;/a&gt; in the past two years, almost all of them written by LLMs. This article presents a collection of useful patterns I've discovered along the way.&lt;/p&gt;
&lt;p&gt;First, some examples to show the kind of thing I'm talking about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/svg-render?url=https://gist.githubusercontent.com/simonw/aedecb93564af13ac1596810d40cac3c/raw/83e7f3be5b65bba61124684700fa7925d37c36c3/tiger.svg"&gt;svg-render&lt;/a&gt;&lt;/strong&gt; renders SVG code to downloadable JPEGs or PNGs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/pypi-changelog?package=llm&amp;amp;compare=0.27...0.27.1"&gt;pypi-changelog&lt;/a&gt;&lt;/strong&gt; lets you generate (and copy to clipboard) diffs between different PyPI package releases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/bluesky-thread?url=https%3A%2F%2Fbsky.app%2Fprofile%2Fsimonwillison.net%2Fpost%2F3m7gzjew3ss2e&amp;amp;view=thread"&gt;bluesky-thread&lt;/a&gt;&lt;/strong&gt; provides a nested view of a discussion thread on Bluesky.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/svg-render?url=https://gist.githubusercontent.com/simonw/aedecb93564af13ac1596810d40cac3c/raw/83e7f3be5b65bba61124684700fa7925d37c36c3/tiger.svg" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/svg-render.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of svg-render" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/pypi-changelog?package=llm&amp;amp;compare=0.27...0.27.1" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/pypi-changelog.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of pypi-changelog" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/bluesky-thread?url=https%3A%2F%2Fbsky.app%2Fprofile%2Fsimonwillison.net%2Fpost%2F3m7gzjew3ss2e&amp;amp;view=thread" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/bluesky-thread.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of bluesky-thread" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;p&gt;These are some of my recent favorites. I have dozens more like this that I use on a regular basis.&lt;/p&gt;
&lt;p&gt;You can explore my collection on &lt;strong&gt;&lt;a href="https://tools.simonwillison.net/"&gt;tools.simonwillison.net&lt;/a&gt;&lt;/strong&gt; - the &lt;a href="https://tools.simonwillison.net/by-month"&gt;by month&lt;/a&gt; view is useful for browsing the entire collection.&lt;/p&gt;
&lt;p&gt;If you want to see the code and prompts, almost all of the examples in this post include a link in their footer to "view source" on GitHub. The GitHub commits usually contain either the prompt itself or a link to the transcript used to create the tool.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#the-anatomy-of-an-html-tool"&gt;The anatomy of an HTML tool&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#prototype-with-artifacts-or-canvas"&gt;Prototype with Artifacts or Canvas&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#switch-to-a-coding-agent-for-more-complex-projects"&gt;Switch to a coding agent for more complex projects&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#load-dependencies-from-cdns"&gt;Load dependencies from CDNs&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#host-them-somewhere-else"&gt;Host them somewhere else&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#take-advantage-of-copy-and-paste"&gt;Take advantage of copy and paste&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#build-debugging-tools"&gt;Build debugging tools&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#persist-state-in-the-url"&gt;Persist state in the URL&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#use-localstorage-for-secrets-or-larger-state"&gt;Use localStorage for secrets or larger state&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#collect-cors-enabled-apis"&gt;Collect CORS-enabled APIs&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#llms-can-be-called-directly-via-cors"&gt;LLMs can be called directly via CORS&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#don-t-be-afraid-of-opening-files"&gt;Don't be afraid of opening files&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#you-can-offer-downloadable-files-too"&gt;You can offer downloadable files too&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#pyodide-can-run-python-code-in-the-browser"&gt;Pyodide can run Python code in the browser&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#webassembly-opens-more-possibilities"&gt;WebAssembly opens more possibilities&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#remix-your-previous-tools"&gt;Remix your previous tools&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#record-the-prompt-and-transcript"&gt;Record the prompt and transcript&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/#go-forth-and-build"&gt;Go forth and build&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="the-anatomy-of-an-html-tool"&gt;The anatomy of an HTML tool&lt;/h4&gt;
&lt;p&gt;These are the characteristics I have found to be most productive in building tools of this nature:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A single file: inline JavaScript and CSS in a single HTML file means the least hassle in hosting or distributing them, and crucially means you can copy and paste them out of an LLM response.&lt;/li&gt;
&lt;li&gt;Avoid React, or anything with a build step. The problem with React is that JSX requires a build step, which makes everything massively less convenient. I prompt "no react" and skip that whole rabbit hole entirely.&lt;/li&gt;
&lt;li&gt;Load dependencies from a CDN. The fewer dependencies the better, but if there's a well known library that helps solve a problem I'm happy to load it from CDNjs or jsdelivr or similar.&lt;/li&gt;
&lt;li&gt;Keep them small. A few hundred lines means the maintainability of the code doesn't matter too much: any good LLM can read them and understand what they're doing, and rewriting them from scratch with help from an LLM takes just a few minutes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The end result is a few hundred lines of code that can be cleanly copied and pasted into a GitHub repository.&lt;/p&gt;
&lt;h4 id="prototype-with-artifacts-or-canvas"&gt;Prototype with Artifacts or Canvas&lt;/h4&gt;
&lt;p&gt;The easiest way to build one of these tools is to start in ChatGPT or Claude or Gemini. All three have features where they can write a simple HTML+JavaScript application and show it to you directly.&lt;/p&gt;
&lt;p&gt;Claude calls this "Artifacts", ChatGPT and Gemini both call it "Canvas". Claude has the feature enabled by default, ChatGPT and Gemini may require you to toggle it on in their "tools" menus.&lt;/p&gt;
&lt;p&gt;Try this prompt in Gemini or ChatGPT:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Build a canvas that lets me paste in JSON and converts it to YAML. No React.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Or this prompt in Claude:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Build an artifact that lets me paste in JSON and converts it to YAML. No React.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I always add "No React" to these prompts, because otherwise they tend to build with React, resulting in a file that is harder to copy and paste out of the LLM and use elsewhere. I find that attempts which use React take longer to display (since they need to run a build step) and are more likely to contain crashing bugs for some reason, especially in ChatGPT.&lt;/p&gt;
&lt;p&gt;All three tools have "share" links that provide a URL to the finished application. Examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://chatgpt.com/canvas/shared/6938e8ece53c8191a2f9d7dfcd090d11"&gt;ChatGPT JSON to YAML Canvas&lt;/a&gt; made with GPT-5.1 Thinking - here's &lt;a href="https://chatgpt.com/share/6938e926-ee14-8006-9678-383b3a8dac78"&gt;the full ChatGPT transcript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://claude.ai/public/artifacts/61fdecb8-6e3b-4162-a5ab-6720dfe5ed19"&gt;Claude JSON to YAML Artifact&lt;/a&gt; made with Claude Opus 4.5 - here's &lt;a href="https://claude.ai/share/421bacb9-54b4-45b4-b41c-a436bc0ebd53"&gt;the full Claude transcript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://gemini.google.com/share/03c1ac87aa40"&gt;Gemini JSON to YAML Canvas&lt;/a&gt; made with Gemini 3 Pro - here's &lt;a href="https://gemini.google.com/share/1e27a1d8cdca"&gt;the full Gemini transcript&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="switch-to-a-coding-agent-for-more-complex-projects"&gt;Switch to a coding agent for more complex projects&lt;/h4&gt;
&lt;p&gt;Coding agents such as Claude Code and Codex CLI have the advantage that they can test the code themselves while they work on it using tools like Playwright. I often upgrade to one of those when I'm working on something more complicated, like my Bluesky thread viewer tool shown above.&lt;/p&gt;
&lt;p&gt;I also frequently use &lt;strong&gt;asynchronous coding agents&lt;/strong&gt; like Claude Code for web to make changes to existing tools. I shared a video about that in &lt;a href="https://simonwillison.net/2025/Oct/23/claude-code-for-web-video/"&gt;Building a tool to copy-paste share terminal sessions using Claude Code for web&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Claude Code for web and Codex Cloud run directly against my &lt;a href="https://github.com/simonw/tools"&gt;simonw/tools&lt;/a&gt; repo, which means they can publish or upgrade tools via Pull Requests (here are &lt;a href="https://github.com/simonw/tools/pulls?q=is%3Apr+is%3Aclosed"&gt;dozens of examples&lt;/a&gt;) without me needing to copy and paste anything myself.&lt;/p&gt;
&lt;h4 id="load-dependencies-from-cdns"&gt;Load dependencies from CDNs&lt;/h4&gt;
&lt;p&gt;Any time I use an additional JavaScript library as part of my tool I like to load it from a CDN.&lt;/p&gt;
&lt;p&gt;The three major LLM platforms support specific CDNs as part of their Artifacts or Canvas features, so often if you tell them "Use PDF.js" or similar they'll be able to compose a URL to a CDN that's on their allow-list.&lt;/p&gt;
&lt;p&gt;Sometimes you'll need to go and look up the URL on &lt;a href="https://cdnjs.com/"&gt;cdnjs&lt;/a&gt; or &lt;a href="https://www.jsdelivr.com/"&gt;jsDelivr&lt;/a&gt; and paste it into the chat.&lt;/p&gt;
&lt;p&gt;CDNs like these have been around for long enough that I've grown to trust them, especially for URLs that include the package version.&lt;/p&gt;
&lt;p&gt;The alternative to CDNs is to use npm and have a build step for your projects. I find this reduces my productivity at hacking on individual tools and makes it harder to self-host them.&lt;/p&gt;
&lt;h4 id="host-them-somewhere-else"&gt;Host them somewhere else&lt;/h4&gt;
&lt;p&gt;I don't like leaving my HTML tools hosted by the LLM platforms themselves for a couple of reasons. First, LLM platforms tend to run the tools inside a tight sandbox with a lot of restrictions. They're often unable to load data or images from external URLs, and sometimes even features like linking out to other sites are disabled.&lt;/p&gt;
&lt;p&gt;The end-user experience often isn't great either. They show warning messages to new users, often take additional time to load and delight in showing promotions for the platform that was used to create the tool.&lt;/p&gt;
&lt;p&gt;They're also not as reliable as other forms of static hosting. If ChatGPT or Claude are having an outage I'd like to still be able to access the tools I've created in the past.&lt;/p&gt;
&lt;p&gt;Being able to easily self-host is the main reason I like insisting on "no React" and using CDNs for dependencies - the absence of a build step makes hosting tools elsewhere a simple case of copying and pasting them out to some other provider.&lt;/p&gt;
&lt;p&gt;My preferred provider here is &lt;a href="https://docs.github.com/en/pages"&gt;GitHub Pages&lt;/a&gt; because I can paste a block of HTML into a file on github.com and have it hosted on a permanent URL a few seconds later. Most of my tools end up in my &lt;a href="https://github.com/simonw/tools"&gt;simonw/tools&lt;/a&gt; repository which is configured to serve static files at &lt;a href="https://tools.simonwillison.net/"&gt;tools.simonwillison.net&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="take-advantage-of-copy-and-paste"&gt;Take advantage of copy and paste&lt;/h4&gt;
&lt;p&gt;One of the most useful input/output mechanisms for HTML tools comes in the form of &lt;strong&gt;copy and paste&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I frequently build tools that accept pasted content, transform it in some way and let the user copy it back to their clipboard to paste somewhere else.&lt;/p&gt;
&lt;p&gt;Copy and paste on mobile phones is fiddly, so I frequently include "Copy to clipboard" buttons that populate the clipboard with a single touch.&lt;/p&gt;
&lt;p&gt;Most operating system clipboards can carry multiple formats of the same copied data. That's why you can paste content from a word processor in a way that preserves formatting, but if you paste the same thing into a text editor you'll get the content with formatting stripped.&lt;/p&gt;
&lt;p&gt;These rich copy operations are available in JavaScript paste events as well, which opens up all sorts of opportunities for HTML tools.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/hacker-news-thread-export"&gt;hacker-news-thread-export&lt;/a&gt;&lt;/strong&gt; lets you paste in a URL to a Hacker News thread and gives you a copyable condensed version of the entire thread, suitable for pasting into an LLM to get a useful summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/paste-rich-text"&gt;paste-rich-text&lt;/a&gt;&lt;/strong&gt; lets you copy from a page and paste to get the HTML - particularly useful on mobile where view-source isn't available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/alt-text-extractor"&gt;alt-text-extractor&lt;/a&gt;&lt;/strong&gt; lets you paste in images and then copy out their alt text.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/hacker-news-thread-export" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/hacker-news-thread-export.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of hacker-news-thread-export" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/paste-rich-text" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/paste-rich-text.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of paste-rich-text" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/alt-text-extractor" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/alt-text-extractor.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of alt-text-extractor" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="build-debugging-tools"&gt;Build debugging tools&lt;/h4&gt;
&lt;p&gt;The key to building interesting HTML tools is understanding what's possible. Building custom debugging tools is a great way to explore these options.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/clipboard-viewer"&gt;clipboard-viewer&lt;/a&gt;&lt;/strong&gt; is one of my most useful. You can paste anything into it (text, rich text, images, files) and it will loop through and show you every type of paste data that's available on the clipboard.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/clipboard-viewer.jpg" alt="Clipboard Format Viewer. Paste anywhere on the page (Ctrl+V or Cmd+V). This shows text/rtf with a bunch of weird code, text/plain with some pasted HTML diff and a Clipboard Event Information panel that says Event type: paste, Formats available: text/rtf, text/plain, 0 files reported and 2 clipboard items reported." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This was key to building many of my other tools, because it showed me the invisible data that I could use to bootstrap other interesting pieces of functionality.&lt;/p&gt;
&lt;p&gt;More debugging examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/keyboard-debug"&gt;keyboard-debug&lt;/a&gt;&lt;/strong&gt; shows the keys (and &lt;code&gt;KeyCode&lt;/code&gt; values) currently being held down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/cors-fetch"&gt;cors-fetch&lt;/a&gt;&lt;/strong&gt; reveals if a URL can be accessed via CORS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/exif"&gt;exif&lt;/a&gt;&lt;/strong&gt; displays EXIF data for a selected photo.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/keyboard-debug" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/keyboard-debug.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of keyboard-debug" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/cors-fetch" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/cors-fetch.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of cors-fetch" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/exif" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/exif.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of exif" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="persist-state-in-the-url"&gt;Persist state in the URL&lt;/h4&gt;
&lt;p&gt;HTML tools may not have access to server-side databases for storage but it turns out you can store a &lt;em&gt;lot&lt;/em&gt; of state directly in the URL.&lt;/p&gt;
&lt;p&gt;I like this for tools I may want to bookmark or share with other people.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/icon-editor#cmdiKDIwMSwgNDYsIDg2KSxyZ2IoMjIzLCA0OCwgOTIpLHJnYigzNCwgODAsIDE3OSkscmdiKDIzNywgNTYsIDk1KSxyZ2IoMTgzLCA1MywgOTYpLHJnYigzOCwgMTA3LCAyMTApLHJnYigyMDQsIDY1LCAxMDUpLHJnYigxNzksIDEwMywgMTM2KSxyZ2IoMjMyLCA5NywgMTQ4KSxyZ2IoMzgsIDkxLCAyMDkpLHJnYigzNiwgOTUsIDIwNCkscmdiKDE5NSwgODYsIDEyOSkscmdiKDE3MywgMzEsIDU4KSxyZ2IoMjEyLCA2MSwgMTA2KSxyZ2IoOTIsIDEwNSwgMTg4KSxyZ2IoMjM3LCA3MSwgMTIzKSxyZ2IoMzksIDk2LCAyMTkpLHJnYigyOCwgODYsIDIxMCkscmdiKDIyMywgMjEyLCAzNCkscmdiKDE3MywgMTUzLCAyNikscmdiKDE0NCwgNzksIDI4KSxyZ2IoMjI0LCA1NiwgOTcpLHJnYigxOTYsIDQ4LCA4NSkscmdiKDIyMCwgNTAsIDk4KSxyZ2IoMTY2LCAxMjYsIDI1KSxyZ2IoMjA5LCAxMzAsIDE5KSxyZ2IoMTg3LCAxMTQsIDEzKSxyZ2IoMTQ3LCAxMDQsIDE4KSxyZ2IoMjE2LCA1OCwgODEpLHJnYigxNTIsIDM5LCA2NCkscmdiKDMyLCA3NSwgMTczKSxyZ2IoMTY2LCAxMjYsIDI5KSxyZ2IoMjM3LCAxODAsIDU0KSxyZ2IoMjA0LCAxMzgsIDIyKSxyZ2IoMTgxLCAxMjksIDIzKSxyZ2IoMjM0LCA4NiwgNzYpLHJnYigxOTAsIDY4LCA3NSkscmdiKDI0NSwgODksIDEzNSkscmdiKDIxMywgNjcsIDExMSkscmdiKDE0MSwgMzEsIDU2KSxyZ2IoNzIsIDc5LCAxMTYpLHJnYigxODcsIDE1NCwgNTIpLHJnYigyMDcsIDE3OSwgNzIpLHJnYigyMTAsIDE2MiwgNDMpLHJnYigyMTQsIDE0OSwgMzEpLHJnYigyMzksIDkwLCA4NCkscmdiKDIzNSwgMTMyLCA3NykscmdiKDE4MSwgMTM4LCAyOSkscmdiKDI0NSwgMTI4LCAxNzgpLHJnYigyMTcsIDk5LCAxNDUpLHJnYigxMTYsIDEwNSwgMTIyKSxyZ2IoMjA2LCAxNzYsIDY1KSxyZ2IoMTkxLCAxNjMsIDY0KSxyZ2IoMjA1LCAxNjksIDU4KSxyZ2IoMjM2LCAxNjUsIDQ2KSxyZ2IoMjM3LCA3OSwgODUpLHJnYigyMzUsIDE0NCwgODcpLHJnYigyNDksIDIwMiwgNDUpLHJnYigyMTAsIDE2NiwgMzQpLHJnYigyMjcsIDEwMywgMTYyKSxyZ2IoMjEzLCA5MCwgMTMwKSxyZ2IoNDQsIDQ4LCAxMjMpLHJnYigxMjUsIDg2LCAxNTEpLHJnYigxOTAsIDE2MywgMTA2KSxyZ2IoMTk5LCAxNjYsIDQ4KSxyZ2IoMjAyLCAxNjQsIDU2KSxyZ2IoMjIxLCAxNzAsIDUzKSxyZ2IoMjM0LCAxMzUsIDc1KSxyZ2IoMjQxLCAxNzUsIDc1KSxyZ2IoMjU1LCAyMjIsIDY1KSxyZ2IoMjU0LCAyMjYsIDY5KSxyZ2IoMjM1LCAyMDEsIDQ0KSxyZ2IoNzMsIDEzNywgMjQ3KSxyZ2IoODAsIDE0MywgMjQ4KSxyZ2IoNzksIDEzOSwgMjQzKSxyZ2IoMTM4LCA5MiwgMTc0KSxyZ2IoMTU2LCAxMTMsIDE3NikscmdiKDIwMSwgMTY4LCA2MykscmdiKDIxMSwgMTY5LCA0NikscmdiKDIxNCwgMTcxLCA1NSkscmdiKDIyOCwgMTgyLCA1NikscmdiKDI0MywgMTk1LCA1OCkscmdiKDI0NSwgMjA0LCA2NykscmdiKDI1NSwgMjIxLCA2NykscmdiKDI1NSwgMjI2LCA2OCkscmdiKDE1NCwgMTYyLCAxMzMpLHJnYigyNiwgMTA1LCAyNTUpLHJnYig2OCwgMTI5LCAyNTIpLHJnYig4NywgMTM1LCAyNDQpLHJnYig4MywgMTMxLCAyMzUpLHJnYig4MiwgMTI3LCAyMjYpLHJnYig4NSwgMTMwLCAyMjcpLHJnYig3OSwgMTIyLCAyMTgpLHJnYigxNjcsIDE0NiwgMzIpLHJnYigxNzQsIDEzOCwgMTI0KSxyZ2IoMTMzLCA2OSwgMjA1KSxyZ2IoMTcxLCAxMjAsIDE0NCkscmdiKDIxNSwgMTc2LCA1NykscmdiKDIyMCwgMTc1LCA0OSkscmdiKDIyMywgMTc5LCA1OCkscmdiKDIzNywgMTg4LCA2MCkscmdiKDI0MSwgMTkxLCA1NikscmdiKDIwMCwgMTc2LCAxMDUpLHJnYigxMTIsIDE0MSwgMjAzKSxyZ2IoODQsIDEyNywgMjM1KSxyZ2IoMTE1LCAxMzgsIDE5MSkscmdiKDgyLCAxMDMsIDE3NCkscmdiKDE1OCwgNDEsIDc2KSxyZ2IoMTcwLCA0MywgNjQpLHJnYigxOTAsIDE1NywgNTApLHJnYigyMDMsIDE3NywgNjUpLHJnYigxNjEsIDEwMiwgMTQyKSxyZ2IoMTQxLCA1OSwgMjA5KSxyZ2IoMTgwLCAxMjIsIDE1MSkscmdiKDIyOCwgMTg1LCA1OCkscmdiKDIzMywgMTg2LCA1MikscmdiKDI0MCwgMTg5LCA2NikscmdiKDI1NCwgMjEwLCA2OCkscmdiKDIwMSwgMTkxLCAxMTMpLHJnYigxMzcsIDEzOSwgMTU3KSxyZ2IoMjExLCAxNjIsIDg4KSxyZ2IoMjUwLCAyMDAsIDUwKSxyZ2IoMTc5LCAxMzEsIDIzKSxyZ2IoMTk2LCAxNjUsIDY0KSxyZ2IoMjA1LCAxNzQsIDU0KSxyZ2IoMjA5LCAxNjAsIDU5KSxyZ2IoMTY2LCA5MSwgMTYxKSxyZ2IoMTQyLCA2MCwgMjIzKSxyZ2IoMTk3LCAxMzksIDE1MCkscmdiKDI0MCwgMTk2LCA3MikscmdiKDI1MSwgMjA4LCA2MSkscmdiKDI1NSwgMjI0LCA4MCkscmdiKDI1NSwgMjUwLCA5MikscmdiKDI1NSwgMjM0LCA4OSkscmdiKDI0OSwgMTg2LCA1MSkscmdiKDI1MCwgMTgwLCAzOSkscmdiKDI0MCwgMTY2LCAzNSkscmdiKDIwMiwgMTc0LCA3MikscmdiKDIxNSwgMTY4LCA1MCkscmdiKDIyMiwgMTc1LCA0MykscmdiKDIxMiwgMTY1LCA2OSkscmdiKDE3NCwgMTAzLCAxNjcpLHJnYigxNjAsIDc4LCAyMzQpLHJnYigyMDUsIDE0NiwgMTg0KSxyZ2IoMjQ3LCAyMTgsIDEwOCkscmdiKDI1NSwgMjQ4LCA4NSkscmdiKDI1NSwgMjU1LCAxMDIpLHJnYigyNTUsIDI1NSwgMTIyKSxyZ2IoMjQwLCAyMTAsIDgyKSxyZ2IoMjE0LCAxNTAsIDMxKSxyZ2IoMjI0LCAxNTAsIDI1KSxyZ2IoMTc2LCAxMjEsIDI1KSxyZ2IoMTg5LCAxODMsIDUyKSxyZ2IoMTIyLCA4MCwgMTU4KSxyZ2IoMTkxLCAxNTEsIDEyMikscmdiKDIyOSwgMTc0LCA0MCkscmdiKDIyNSwgMTcyLCA1MSkscmdiKDIyOSwgMTg1LCA1MSkscmdiKDIzNywgMTkwLCA2MCkscmdiKDIwOSwgMTQ2LCAxNjEpLHJnYigxOTUsIDExNywgMjUxKSxyZ2IoMjI1LCAxNTUsIDIzOSkscmdiKDI1NCwgMjI3LCAxODQpLHJnYigyNTUsIDI1NSwgMTE3KSxyZ2IoMjQ5LCAyMzcsIDc2KSxyZ2IoMjA0LCAxNjcsIDU1KSxyZ2IoMTU3LCAxMTUsIDI1KSxyZ2IoMTM1LCA5OCwgMTYpLHJnYigyMDMsIDEyNSwgNTcpLHJnYigxOTgsIDEyNSwgNTMpLHJnYigxNTcsIDExMCwgMTQ0KSxyZ2IoMTQ5LCA4NCwgMTk0KSxyZ2IoMjEyLCAxNTcsIDk0KSxyZ2IoMjMyLCAxODUsIDQ3KSxyZ2IoMjM1LCAxODYsIDYyKSxyZ2IoMjUwLCAyMDQsIDY1KSxyZ2IoMjUzLCAyMzIsIDgxKSxyZ2IoMjQzLCAyMTUsIDE0OCkscmdiKDI0NywgMTgzLCAyMzMpLHJnYigyNDMsIDE2MywgMjUwKSxyZ2IoMTk4LCAxMzgsIDE3NSkscmdiKDE2MCwgMTEzLCA4MikscmdiKDEyNCwgODksIDM3KSxyZ2IoMTU3LCAxMzYsIDM2KSxyZ2IoMjAzLCAxNjQsIDgyKSxyZ2IoMTQ4LCA3MiwgMTg5KSxyZ2IoMTU4LCA4NCwgMjA0KSxyZ2IoMjE3LCAxNjgsIDExNykscmdiKDI1MCwgMjEwLCA2NykscmdiKDI1NSwgMjI5LCA3OCkscmdiKDI1NSwgMjU1LCA5NikscmdiKDI1NSwgMjU1LCA5NCkscmdiKDI0MywgMjE4LCA5NSkscmdiKDE3OCwgMTE4LCAxMDYpLHJnYigxMDMsIDQwLCAxMDIpLHJnYigxODgsIDExMSwgMjcpLHJnYigxODMsIDE1NiwgNTkpLHJnYigyMTUsIDE3NiwgNDgpLHJnYigyMDMsIDE0OCwgOTEpLHJnYigxNjcsIDg5LCAxOTcpLHJnYigxNzgsIDEwMywgMjM1KSxyZ2IoMjM1LCAxOTMsIDE3NSkscmdiKDI1NSwgMjUxLCAxMjQpLHJnYigyNDksIDI0MCwgOTIpLHJnYigyMTMsIDE4NiwgNjApLHJnYigxNjAsIDEyMSwgMjEpLHJnYigxOTEsIDE1NSwgMTA4KSxyZ2IoMjIxLCAxODAsIDQwKSxyZ2IoMjM3LCAxODksIDQ3KSxyZ2IoMjMzLCAxODYsIDk2KSxyZ2IoMjE5LCAxNjIsIDIwNykscmdiKDIzMSwgMTU5LCAyNDkpLHJnYigyMTAsIDE1OCwgMTkxKSxyZ2IoMTY5LCAxMzAsIDc1KSxyZ2IoMTQwLCA5NiwgMTE5KSxyZ2IoMTU1LCA4NSwgMjAwKSxyZ2IoMjA5LCAxNTcsIDExNSkscmdiKDI1NCwgMjI2LCA3MCkscmdiKDI1NSwgMjU1LCA4MCkscmdiKDIzNSwgMjE3LCA3NikscmdiKDE3OCwgMTMzLCA5MSkscmdiKDIwOSwgMTEwLCAxNTEpLHJnYigxNTIsIDExOCwgNTYpLHJnYigxODYsIDExNiwgMTY4KSxyZ2IoMTkzLCAxMjEsIDIzNikscmdiKDIyOSwgMTk1LCAxNjEpLHJnYigxOTcsIDE4MCwgNzUpLHJnYigxOTksIDE1OCwgNzApLHJnYigxOTcsIDE0OCwgMTM2KXxfX19fX19fXzAxX19fX19fX19fX19fX19fMl9fX19fX18zNDVfX19fX182X183OF9fOWFfX19fX2JjZGVfX19fX19fX19fZl9fX2doX2lqa19fbF9fX19fX19fbV9uX19fX19fX19vcHFyc19fX19fX19fdF9fX19fX3VfX192d3h5ejEwX19fMTExMl9fMTNfX19fX19fX18xNDE1MTYxNzE4MTkxYTFiX18xYzFkX19fX19fX19fX19fMWUxZjFnMWgxaTFqMWsxbDFtXzFuMW9fX19fX19fX19fXzFwMXExcjFzMXQxdTF2MXcxeDF5MXpfX19fXzIwMjEyMl9fX19fXzIzMjQyNTI2MjcyODI5MmEyYjJjMmQyZTJmMmcyaDJpMmoya19fX19fMmwybTJuMm8ycDJxMnIyczJ0MnUydjJ3MngyeV9fX19fX19fMnozMDMxMzIzMzM0MzUzNjM3MzgzOTNhM2IzYzNkM2VfX19fX19fX19fM2YzZzNoM2kzajNrM2wzbTNuM28zcDNxM3Izc19fX19fX19fX18zdDN1M3YzdzN4M3kzejQwNDE0MjQzNDQ0NTQ2NDc0OF9fX19fX180OTRhNGI0YzRkNGU0ZjRnNGg0aTRqNGs0bDRtNG5fX180bzRwX19fXzRxNHI0czR0NHU0djR3NHg0eTR6NTA1MTUyX19fX19fX19fXzUzNTQ1NTU2NTc1ODU5NWE1YjVjNWQ1ZV9fX19fXzVmX19fX181ZzVoNWk1ajVrNWw1bTVuNW81cF9fX19fX19fX19fX19fNXE1cjVzNXQ1dTV2NXc1eF9fX19fX19fX19fX19fXzV5NXo2MDYxNjI2MzY0X19fX19fX19fX19fNjVfX19fNjY2NzY4Njk2YV9fX19fX19fX19fX19fX19fX19fNmI2Y19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19f"&gt;icon-editor&lt;/a&gt;&lt;/strong&gt; is a custom 24x24 icon editor I built to help hack on icons for &lt;a href="https://simonwillison.net/2025/Oct/28/github-universe-badge/"&gt;the GitHub Universe badge&lt;/a&gt;. It persists your in-progress icon design in the URL so you can easily bookmark and share it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="use-localstorage-for-secrets-or-larger-state"&gt;Use localStorage for secrets or larger state&lt;/h4&gt;
&lt;p&gt;The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage"&gt;localStorage&lt;/a&gt; browser API lets HTML tools store data persistently on the user's device, without exposing that data to the server.&lt;/p&gt;
&lt;p&gt;I use this for larger pieces of state that don't fit comfortably in a URL, or for secrets like API keys which I really don't want anywhere near my server  - even static hosts might have server logs that are outside of my influence.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/word-counter"&gt;word-counter&lt;/a&gt;&lt;/strong&gt; is a simple tool I built to help me write to specific word counts, for things like conference abstract submissions. It uses localStorage to save as you type, so your work isn't lost if you accidentally close the tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/render-markdown"&gt;render-markdown&lt;/a&gt;&lt;/strong&gt; uses the same trick - I sometimes use this one to craft blog posts and I don't want to lose them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/haiku"&gt;haiku&lt;/a&gt;&lt;/strong&gt; is one of a number of LLM demos I've built that request an API key from the user (via the &lt;code&gt;prompt()&lt;/code&gt; function) and then store that in &lt;code&gt;localStorage&lt;/code&gt;. This one uses Claude Haiku to write haikus about what it can see through the user's webcam.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/word-counter" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/word-counter.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of word-counter" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/render-markdown" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/render-markdown.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of render-markdown" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/haiku" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/haiku.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of haiku" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="collect-cors-enabled-apis"&gt;Collect CORS-enabled APIs&lt;/h4&gt;
&lt;p&gt;CORS stands for &lt;a href="https://en.wikipedia.org/wiki/Cross-origin_resource_sharing"&gt;Cross-origin resource sharing&lt;/a&gt;. It's a relatively low-level detail which controls if JavaScript running on one site is able to fetch data from APIs hosted on other domains.&lt;/p&gt;
&lt;p&gt;APIs that provide open CORS headers are a goldmine for HTML tools. It's worth building a collection of these over time.&lt;/p&gt;
&lt;p&gt;Here are some I like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iNaturalist for fetching sightings of animals, including URLs to photos&lt;/li&gt;
&lt;li&gt;PyPI for fetching details of Python packages&lt;/li&gt;
&lt;li&gt;GitHub because anything in a public repository in GitHub has a CORS-enabled anonymous API for fetching that content from the raw.githubusercontent.com domain, which is behind a caching CDN so you don't need to worry too much about rate limits or feel guilty about adding load to their infrastructure.&lt;/li&gt;
&lt;li&gt;Bluesky for all sorts of operations&lt;/li&gt;
&lt;li&gt;Mastodon has generous CORS policies too, as used by applications like &lt;a href="https://phanpy.social/"&gt;phanpy.social&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;GitHub Gists are a personal favorite here, because they let you build apps that can persist state to a permanent Gist through making a cross-origin API call.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/species-observation-map#%7B%22taxonId%22%3A123829%2C%22taxonName%22%3A%22California%20Brown%20Pelican%22%2C%22days%22%3A%2230%22%7D"&gt;species-observation-map&lt;/a&gt;&lt;/strong&gt; uses iNaturalist to show a map of recent sightings of a particular species.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/zip-wheel-explorer?package=llm"&gt;zip-wheel-explorer&lt;/a&gt;&lt;/strong&gt; fetches a &lt;code&gt;.whl&lt;/code&gt; file for a Python package from PyPI, unzips it (in browser memory) and lets you navigate the files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/github-issue-to-markdown?issue=https%3A%2F%2Fgithub.com%2Fsimonw%2Fsqlite-utils%2Fissues%2F657"&gt;github-issue-to-markdown&lt;/a&gt;&lt;/strong&gt; fetches issue details and comments from the GitHub API (including expanding any permanent code links) and turns them into copyable Markdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/terminal-to-html"&gt;terminal-to-html&lt;/a&gt;&lt;/strong&gt; can optionally save the user's converted terminal session to a Gist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/bluesky-quote-finder?post=https%3A%2F%2Fbsky.app%2Fprofile%2Fsimonwillison.net%2Fpost%2F3m7auwt3ma222"&gt;bluesky-quote-finder&lt;/a&gt;&lt;/strong&gt; displays quotes of a specified Bluesky post, which can then be sorted by likes or by time.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/species-observation-map#%7B%22taxonId%22%3A123829%2C%22taxonName%22%3A%22California%20Brown%20Pelican%22%2C%22days%22%3A%2230%22%7D" style="flex: 1; width: 20%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/species-observation-map.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of species-observation-map" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/zip-wheel-explorer?package=llm" style="flex: 1; width: 20%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/zip-wheel-explorer.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of zip-wheel-explorer" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/github-issue-to-markdown?issue=https%3A%2F%2Fgithub.com%2Fsimonw%2Fsqlite-utils%2Fissues%2F657" style="flex: 1; width: 20%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/github-issue-to-markdown.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of github-issue-to-markdown" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/terminal-to-html" style="flex: 1; width: 20%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/terminal-to-html.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of terminal-to-html" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/bluesky-quote-finder?post=https%3A%2F%2Fbsky.app%2Fprofile%2Fsimonwillison.net%2Fpost%2F3m7auwt3ma222" style="flex: 1; width: 20%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/bluesky-quote-finder.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of bluesky-quote-finder" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="llms-can-be-called-directly-via-cors"&gt;LLMs can be called directly via CORS&lt;/h4&gt;
&lt;p&gt;All three of OpenAI, Anthropic and Gemini offer JSON APIs that can be accessed via CORS directly from HTML tools.&lt;/p&gt;
&lt;p&gt;Unfortunately you still need an API key, and if you bake that key into your visible HTML anyone can steal it and use to rack up charges on your account.&lt;/p&gt;
&lt;p&gt;I use the &lt;code&gt;localStorage&lt;/code&gt; secrets pattern to store API keys for these services. This sucks from a user experience perspective - telling users to go and create an API key and paste it into a tool is a lot of friction - but it does work.&lt;/p&gt;
&lt;p&gt;Some examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/haiku"&gt;haiku&lt;/a&gt;&lt;/strong&gt; uses the Claude API to write a haiku about an image from the user's webcam.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/openai-audio-output"&gt;openai-audio-output&lt;/a&gt;&lt;/strong&gt; generates audio speech using OpenAI's GPT-4o audio API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="http://tools.simonwillison.net/gemini-bbox"&gt;gemini-bbox&lt;/a&gt;&lt;/strong&gt; demonstrates Gemini 2.5's ability to return complex shaped image masks for objects in images, see &lt;a href="https://simonwillison.net/2025/Apr/18/gemini-image-segmentation/"&gt;Image segmentation using Gemini 2.5&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/haiku" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/haiku.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of haiku" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/openai-audio-output" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/openai-audio-output.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of openai-audio-output" /&gt;&lt;/a&gt;
  &lt;a href="http://tools.simonwillison.net/gemini-bbox" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/gemini-bbox.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of gemini-bbox" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="don-t-be-afraid-of-opening-files"&gt;Don't be afraid of opening files&lt;/h4&gt;
&lt;p&gt;You don't need to upload a file to a server in order to make use of the &lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt; element. JavaScript can access the content of that file directly, which opens up a wealth of opportunities for useful functionality.&lt;/p&gt;
&lt;p&gt;Some examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/ocr"&gt;ocr&lt;/a&gt;&lt;/strong&gt; is the first tool I built for my collection, described in &lt;a href="https://simonwillison.net/2024/Mar/30/ocr-pdfs-images/"&gt;Running OCR against PDFs and images directly in your browser&lt;/a&gt;. It uses &lt;code&gt;PDF.js&lt;/code&gt; and &lt;code&gt;Tesseract.js&lt;/code&gt; to allow users to open a PDF in their browser which it then converts to an image-per-page and runs through OCR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/social-media-cropper"&gt;social-media-cropper&lt;/a&gt;&lt;/strong&gt; lets you open (or paste in) an existing image and then crop it to common dimensions needed for different social media platforms - 2:1 for Twitter and LinkedIn, 1.4:1 for Substack etc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/ffmpeg-crop"&gt;ffmpeg-crop&lt;/a&gt;&lt;/strong&gt; lets you open and preview a video file in your browser, drag a crop box within it and then copy out the &lt;code&gt;ffmpeg&lt;/code&gt; command needed to produce a cropped copy on your own machine.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/ocr" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/ocr.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of ocr" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/social-media-cropper" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/social-media-cropper.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of social-media-cropper" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/ffmpeg-crop" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/ffmpeg-crop.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of ffmpeg-crop" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="you-can-offer-downloadable-files-too"&gt;You can offer downloadable files too&lt;/h4&gt;
&lt;p&gt;An HTML tool can generate a file for download without needing help from a server.&lt;/p&gt;
&lt;p&gt;The JavaScript library ecosystem has a huge range of packages for generating files in all kinds of useful formats.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/svg-render"&gt;svg-render&lt;/a&gt;&lt;/strong&gt; lets the user download the PNG or JPEG rendered from an SVG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/social-media-cropper"&gt;social-media-cropper&lt;/a&gt;&lt;/strong&gt; does the same for cropped images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/open-sauce-2025"&gt;open-sauce-2025&lt;/a&gt;&lt;/strong&gt; is my alternative schedule for a conference that includes a downloadable ICS file for adding the schedule to your calendar. See &lt;a href="https://simonwillison.net/2025/Jul/17/vibe-scraping/"&gt;Vibe scraping and vibe coding a schedule app for Open Sauce 2025 entirely on my phone&lt;/a&gt; for more on that project.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/svg-render" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/svg-render.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of svg-render" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/social-media-cropper" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/social-media-cropper.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of social-media-cropper" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/open-sauce-2025" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/open-sauce-2025.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of open-sauce-2025" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="pyodide-can-run-python-code-in-the-browser"&gt;Pyodide can run Python code in the browser&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; is a distribution of Python that's compiled to WebAssembly and designed to run directly in browsers. It's an engineering marvel and one of the most underrated corners of the Python world.&lt;/p&gt;
&lt;p&gt;It also cleanly loads from a CDN, which means there's no reason not to use it in HTML tools!&lt;/p&gt;
&lt;p&gt;Even better, the Pyodide project includes &lt;a href="https://github.com/pyodide/micropip"&gt;micropip&lt;/a&gt; - a mechanism that can load extra pure-Python packages from PyPI via CORS.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/pyodide-bar-chart"&gt;pyodide-bar-chart&lt;/a&gt;&lt;/strong&gt; demonstrates running Pyodide, Pandas and matplotlib to render a bar chart directly in the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/numpy-pyodide-lab"&gt;numpy-pyodide-lab&lt;/a&gt;&lt;/strong&gt; is an experimental interactive tutorial for Numpy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/apsw-query"&gt;apsw-query&lt;/a&gt;&lt;/strong&gt; demonstrates the &lt;a href="https://github.com/rogerbinns/apsw"&gt;APSW SQLite library&lt;/a&gt;  running in a browser, using it to show EXPLAIN QUERY plans for SQLite queries.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/pyodide-bar-chart" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/pyodide-bar-chart.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of pyodide-bar-chart" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/numpy-pyodide-lab" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/numpy-pyodide-lab.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of numpy-pyodide-lab" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/apsw-query" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/apsw-query.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of apsw-query" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="webassembly-opens-more-possibilities"&gt;WebAssembly opens more possibilities&lt;/h4&gt;
&lt;p&gt;Pyodide is possible thanks to WebAssembly. WebAssembly means that a vast collection of software originally written in other languages can now be loaded in HTML tools as well.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://squoosh.app/"&gt;Squoosh.app&lt;/a&gt; was the first example I saw that convinced me of the power of this pattern - it makes several best-in-class image compression libraries available directly in the browser.&lt;/p&gt;
&lt;p&gt;I've used WebAssembly for a few of my own tools:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/ocr"&gt;ocr&lt;/a&gt;&lt;/strong&gt; uses the pre-existing &lt;a href="https://tesseract.projectnaptha.com/"&gt;Tesseract.js&lt;/a&gt; WebAssembly port of the Tesseract OCR engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/sloccount"&gt;sloccount&lt;/a&gt;&lt;/strong&gt; is a port of David Wheeler's Perl and C &lt;a href="https://dwheeler.com/sloccount/"&gt;SLOCCount&lt;/a&gt; utility to the browser, using a big ball of WebAssembly duct tape. &lt;a href="https://simonwillison.net/2025/Oct/22/sloccount-in-webassembly/"&gt;More details here&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/micropython"&gt;micropython&lt;/a&gt;&lt;/strong&gt; is my experiment using &lt;a href="https://www.npmjs.com/package/@micropython/micropython-webassembly-pyscript"&gt;@micropython/micropython-webassembly-pyscript&lt;/a&gt; from NPM to run Python code with a smaller initial download than Pyodide.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style="display: flex; width: 100%; gap: 20px; margin-bottom: 1em;"&gt;
  &lt;a href="https://tools.simonwillison.net/ocr" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/ocr.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of ocr" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/sloccount" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/sloccount.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of sloccount" /&gt;&lt;/a&gt;
  &lt;a href="https://tools.simonwillison.net/micropython" style="flex: 1; width: 30%; border: none;"&gt;&lt;img src="https://static.simonwillison.net/static/2025/html-tools/micropython.jpg" loading="lazy" style="width: 100%; height: auto; object-fit: cover;" alt="screenshot of micropython" /&gt;&lt;/a&gt;
&lt;/div&gt;
&lt;h4 id="remix-your-previous-tools"&gt;Remix your previous tools&lt;/h4&gt;
&lt;p&gt;The biggest advantage of having a single public collection of 100+ tools is that it's easy for my LLM assistants to recombine them in interesting ways.&lt;/p&gt;
&lt;p&gt;Sometimes I'll copy and paste a previous tool into the context, but when I'm working with a coding agent I can reference them by name - or tell the agent to search for relevant examples before it starts work.&lt;/p&gt;
&lt;p&gt;The source code of any working tool doubles as clear documentation of how something can be done, including patterns for using editing libraries. An LLM with one or two existing tools in their context is much more likely to produce working code.&lt;/p&gt;
&lt;p&gt;I built &lt;strong&gt;&lt;a href="https://tools.simonwillison.net/pypi-changelog"&gt;pypi-changelog&lt;/a&gt;&lt;/strong&gt; by telling Claude Code:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Look at the pypi package explorer tool&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And then, after it had found and read the source code for &lt;a href="https://tools.simonwillison.net/zip-wheel-explorer"&gt;zip-wheel-explorer&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Build a new tool pypi-changelog.html which uses the PyPI API to get the wheel URLs of all available versions of a package, then it displays them in a list where each pair has a "Show changes" clickable in between them - clicking on that fetches the full contents of the wheels and displays a nicely rendered diff representing the difference between the two, as close to a standard diff format as you can get with JS libraries from CDNs, and when that is displayed there is a "Copy" button which copies that diff to the clipboard&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://gistpreview.github.io/?9b48fd3f8b99a204ba2180af785c89d2"&gt;the full transcript&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;See &lt;a href="https://simonwillison.net/2024/Mar/30/ocr-pdfs-images/"&gt;Running OCR against PDFs and images directly in your browser&lt;/a&gt; for another detailed example of remixing tools to create something new.&lt;/p&gt;
&lt;h4 id="record-the-prompt-and-transcript"&gt;Record the prompt and transcript&lt;/h4&gt;
&lt;p&gt;I like keeping (and publishing) records of everything I do with LLMs, to help me grow my skills at using them over time.&lt;/p&gt;
&lt;p&gt;For HTML tools I built by chatting with an LLM platform directly I use the "share" feature for those platforms.&lt;/p&gt;
&lt;p&gt;For Claude Code or Codex CLI or other coding agents I copy and paste the full transcript from the terminal into my &lt;a href="https://tools.simonwillison.net/terminal-to-html"&gt;terminal-to-html&lt;/a&gt; tool and share that using a Gist.&lt;/p&gt;
&lt;p&gt;In either case I include links to those transcripts in the commit message when I save the finished tool to my repository. You can see those &lt;a href="https://tools.simonwillison.net/colophon"&gt;in my tools.simonwillison.net colophon&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="go-forth-and-build"&gt;Go forth and build&lt;/h4&gt;
&lt;p&gt;I've had &lt;em&gt;so much fun&lt;/em&gt; exploring the capabilities of LLMs in this way over the past year and a half, and building tools in this way has been invaluable in helping me understand both the potential for building tools with HTML and the capabilities of the LLMs that I'm building them with.&lt;/p&gt;
&lt;p&gt;If you're interested in starting your own collection I highly recommend it! All you need to get started is a free GitHub repository with GitHub Pages enabled (Settings -&amp;gt; Pages -&amp;gt; Source -&amp;gt; Deploy from a branch -&amp;gt; main) and you can start copying in &lt;code&gt;.html&lt;/code&gt; pages generated in whatever manner you like.&lt;/p&gt;

&lt;p&gt;&lt;small&gt;&lt;strong&gt;Bonus transcript&lt;/strong&gt;: Here's &lt;a href="http://gistpreview.github.io/?1b8cba6a8a21110339cbde370e755ba0"&gt;how I used Claude Code&lt;/a&gt; and &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; to add the screenshots to this post.&lt;/small&gt;&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/html"&gt;html&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/definitions"&gt;definitions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/localstorage"&gt;localstorage&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="html"/><category term="ai"/><category term="llms"/><category term="claude-code"/><category term="vibe-coding"/><category term="webassembly"/><category term="ai-assisted-programming"/><category term="tools"/><category term="javascript"/><category term="coding-agents"/><category term="projects"/><category term="generative-ai"/><category term="github"/><category term="definitions"/><category term="localstorage"/></entry><entry><title>Bluesky Thread Viewer thread by @simonwillison.net</title><link href="https://simonwillison.net/2025/Nov/28/bluesky-thread-viewer/#atom-tag" rel="alternate"/><published>2025-11-28T23:57:22+00:00</published><updated>2025-11-28T23:57:22+00:00</updated><id>https://simonwillison.net/2025/Nov/28/bluesky-thread-viewer/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/bluesky-thread.html?url=https%3A%2F%2Fbsky.app%2Fprofile%2Fsimonwillison.net%2Fpost%2F3m6pmebfass24&amp;amp;view=thread"&gt;Bluesky Thread Viewer thread by @simonwillison.net&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I've been having a lot of fun hacking on my Bluesky Thread Viewer JavaScript tool with Claude Code recently. Here it renders a thread (complete with &lt;a href="https://bsky.app/profile/simonwillison.net/post/3m6pmebfass24"&gt;demo video&lt;/a&gt;) talking about the latest improvements to the tool itself.&lt;/p&gt;
&lt;p&gt;&lt;img alt="This short animated GIF demo starts with the Thread by @simonwillison.net page where a URL to a Bluesky post has been entered and a Fetch Thread button clicked. The thread is shown as a nested collection of replies. A &amp;quot;Hide other replies&amp;quot; button hides the replies revealing just the top-level self-replies by the original author - and turns into a &amp;quot;Show 11 other replies&amp;quot; button when toggled. There are tabs for Thread View and Most Recent First - the latter when clicked shows a linear list of posts with the most recent at the top. There are &amp;quot;Copy&amp;quot; and Copy JSON&amp;quot; green buttons at the top of the page." src="https://static.simonwillison.net/static/2025/bluesky-thread-viewer-demo.gif" /&gt;&lt;/p&gt;
&lt;p&gt;I've been mostly vibe-coding this thing since April, now spanning &lt;a href="https://github.com/simonw/tools/commits/main/bluesky-thread.html"&gt;15 commits&lt;/a&gt; with contributions from ChatGPT, Claude, Claude Code for Web and Claude Code on my laptop. Each of those commits links to the transcript that created the changes in the commit.&lt;/p&gt;
&lt;p&gt;Bluesky is a &lt;em&gt;lot&lt;/em&gt; of fun to build tools like this against because the API supports CORS (so you can talk to it from an HTML+JavaScript page hosted anywhere) and doesn't require authentication.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/bluesky"&gt;bluesky&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cors"&gt;cors&lt;/a&gt;&lt;/p&gt;



</summary><category term="bluesky"/><category term="ai"/><category term="claude-code"/><category term="llms"/><category term="vibe-coding"/><category term="coding-agents"/><category term="tools"/><category term="generative-ai"/><category term="projects"/><category term="cors"/></entry><entry><title>llm-anthropic 0.23</title><link href="https://simonwillison.net/2025/Nov/25/llm-anthropic/#atom-tag" rel="alternate"/><published>2025-11-25T05:26:34+00:00</published><updated>2025-11-25T05:26:34+00:00</updated><id>https://simonwillison.net/2025/Nov/25/llm-anthropic/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-anthropic/releases/tag/0.23"&gt;llm-anthropic 0.23&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New plugin release adding support for Claude Opus 4.5, including the new &lt;code&gt;thinking_effort&lt;/code&gt; option:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm install -U llm-anthropic
llm -m claude-opus-4.5 -o thinking_effort low 'muse on pelicans'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This took longer to release than I had hoped because it was blocked on Anthropic shipping &lt;a href="https://github.com/anthropics/anthropic-sdk-python/releases/tag/v0.75.0"&gt;0.75.0&lt;/a&gt; of their Python library with support for thinking effort.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="llm"/><category term="anthropic"/><category term="claude"/><category term="generative-ai"/><category term="projects"/><category term="ai"/><category term="llms"/></entry><entry><title>sqlite-utils 3.39</title><link href="https://simonwillison.net/2025/Nov/24/sqlite-utils-339/#atom-tag" rel="alternate"/><published>2025-11-24T18:59:14+00:00</published><updated>2025-11-24T18:59:14+00:00</updated><id>https://simonwillison.net/2025/Nov/24/sqlite-utils-339/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://sqlite-utils.datasette.io/en/stable/changelog.html#v3-39"&gt;sqlite-utils 3.39&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I got a report of &lt;a href="https://github.com/simonw/sqlite-utils/issues/687"&gt;a bug&lt;/a&gt; in &lt;code&gt;sqlite-utils&lt;/code&gt; concerning plugin installation - if you installed the package using &lt;code&gt;uv tool install&lt;/code&gt; further attempts to install plugins with &lt;code&gt;sqlite-utils install X&lt;/code&gt; would fail, because &lt;code&gt;uv&lt;/code&gt; doesn't bundle &lt;code&gt;pip&lt;/code&gt; by default. I had the same bug with Datasette &lt;a href="https://github.com/simonw/sqlite-utils/issues/687"&gt;a while ago&lt;/a&gt;, turns out I forgot to apply the fix to &lt;code&gt;sqlite-utils&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Since I was pushing a new dot-release I decided to integrate some of the non-breaking changes from the 4.0 alpha &lt;a href="https://simonwillison.net/2025/Nov/24/sqlite-utils-40a1/"&gt;I released last night&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I tried to have Claude Code do the backporting for me:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;create a new branch called 3.x starting with the 3.38 tag, then consult 
&lt;a href="https://github.com/simonw/sqlite-utils/issues/688"&gt;https://github.com/simonw/sqlite-utils/issues/688&lt;/a&gt; and cherry-pick the commits it lists in the second comment, then review each of the links in the first comment and cherry-pick those as well. After each cherry-pick run the command "just test" to confirm the tests pass and fix them if they don't. Look through the commit history on main since the 3.38 tag to help you with this task.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This worked reasonably well - &lt;a href="https://gistpreview.github.io/?83c7a7ea96d6b7763ad5d72d251ce1a6"&gt;here's the terminal transcript&lt;/a&gt;. It successfully argued me out of two of the larger changes which would have added more complexity than I want in a small dot-release like this.&lt;/p&gt;
&lt;p&gt;I still had to do a bunch of manual work to get everything up to scratch, which I carried out in &lt;a href="https://github.com/simonw/sqlite-utils/pull/689"&gt;this PR&lt;/a&gt; - including adding comments there and then telling Claude Code:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Apply changes from the review on this PR &lt;a href="https://github.com/simonw/sqlite-utils/pull/689"&gt;https://github.com/simonw/sqlite-utils/pull/689&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://gistpreview.github.io/?f4c89636cc58fc7bf9820c06f2488b91"&gt;the transcript from that&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The release is now out with the following release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Fixed a bug with &lt;code&gt;sqlite-utils install&lt;/code&gt; when the tool had been installed using &lt;code&gt;uv&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/687"&gt;#687&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;--functions&lt;/code&gt; argument now optionally accepts a path to a Python file as an alternative to a string full of code, and can be specified multiple times - see &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#cli-query-functions"&gt;Defining custom SQL functions&lt;/a&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/659"&gt;#659&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; now requires on Python 3.10 or higher.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;



</summary><category term="coding-agents"/><category term="projects"/><category term="sqlite"/><category term="claude-code"/><category term="sqlite-utils"/><category term="uv"/><category term="annotated-release-notes"/></entry><entry><title>sqlite-utils 4.0a1 has several (minor) backwards incompatible changes</title><link href="https://simonwillison.net/2025/Nov/24/sqlite-utils-40a1/#atom-tag" rel="alternate"/><published>2025-11-24T14:52:34+00:00</published><updated>2025-11-24T14:52:34+00:00</updated><id>https://simonwillison.net/2025/Nov/24/sqlite-utils-40a1/#atom-tag</id><summary type="html">
    &lt;p&gt;I released a &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#a1-2025-11-23"&gt;new alpha version&lt;/a&gt; of &lt;a href="https://sqlite-utils.datasette.io/"&gt;sqlite-utils&lt;/a&gt; last night - the 128th release of that package since I started building it back in 2018.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;sqlite-utils&lt;/code&gt; is two things in one package: a Python library for conveniently creating and manipulating SQLite databases and a CLI tool for working with them in the terminal. Almost every feature provided by the package is available via both of those surfaces.&lt;/p&gt;
&lt;p&gt;This is hopefully the last alpha before a 4.0 stable release. I use semantic versioning for this library, so the 4.0 version number indicates that there are backward incompatible changes that may affect code written against the 3.x line.&lt;/p&gt;
&lt;p&gt;These changes are mostly very minor: I don't want to break any existing code if I can avoid it. I made it all the way to version 3.38 before I had to ship a major release and I'm sad I couldn't push that even further!&lt;/p&gt;
&lt;p&gt;Here are the &lt;a href="https://simonwillison.net/tags/annotated-release-notes/"&gt;annotated release notes&lt;/a&gt; for 4.0a1.&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This change is for type hint enthusiasts. The Python library used to encourage accessing both SQL tables and SQL views through the &lt;code&gt;db["name_of_table_or_view"]&lt;/code&gt; syntactic sugar - but tables and view have different interfaces since there's no way to handle a &lt;code&gt;.insert(row)&lt;/code&gt; on a SQLite view. If you want clean type hints for your code you can now use the &lt;code&gt;db.table(table_name)&lt;/code&gt; and &lt;code&gt;db.view(view_name)&lt;/code&gt; methods instead.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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/stable/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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;A new feature, not a breaking change. I realized that supporting a stream of lists or tuples as an option for populating large tables would be a neat optimization over always dealing with dictionaries each of which duplicated the column names.&lt;/p&gt;
&lt;p&gt;I had the idea for this one while walking the dog and built the first prototype by prompting Claude Code for web on my phone. Here's &lt;a href="https://github.com/simonw/research/pull/31"&gt;the prompt I used&lt;/a&gt; and the &lt;a href="https://github.com/simonw/research/blob/main/sqlite-utils-iterator-support/README.md"&gt;prototype report it created&lt;/a&gt;, which included a benchmark estimating how much of a performance boost could be had for different sizes of tables.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I was horrified to discover a while ago that I'd been creating SQLite columns called FLOAT but the correct type to use was REAL! This change fixes that. Previously the fix was to ask for tables to be created in strict mode.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;As part of this I also figured out recipes for using &lt;code&gt;uv&lt;/code&gt; as a development environment for the package, which are now baked into the &lt;a href="https://github.com/simonw/sqlite-utils/blob/4.0a1/Justfile"&gt;Justfile&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This one is best explained &lt;a href="https://github.com/simonw/sqlite-utils/issues/655"&gt;in the issue&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;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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Another change which I would have made earlier but, since it introduces a minor behavior change to an existing feature, I reserved it for the 4.0 release.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Back in 2018 when I started this project I was new to working in-depth with SQLite and incorrectly concluded that the correct way to create tables and columns named after reserved words was like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;create table [my table] (
  [id] integer primary key,
  [key] text
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That turned out to be a non-standard SQL syntax which the SQLite documentation &lt;a href="https://sqlite.org/lang_keywords.html"&gt;describes like this&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A keyword enclosed in square brackets is an identifier. This is not standard SQL. This quoting mechanism is used by MS Access and SQL Server and is included in SQLite for compatibility.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Unfortunately I baked it into the library early on and it's been polluting the world with weirdly escaped table and column names ever since!&lt;/p&gt;
&lt;p&gt;I've finally fixed that, with the help of Claude Code which took on the mind-numbing task of &lt;a href="https://github.com/simonw/sqlite-utils/pull/678/files"&gt;updating hundreds of existing tests&lt;/a&gt; that asserted against the generated schemas.&lt;/p&gt;
&lt;p&gt;The above example table schema now looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;create table "my table" (
  "id" integer primary key,
  "key" text
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This may seem like a pretty small change but I expect it to cause a fair amount of downstream pain purely in terms of updating tests that work against tables created by &lt;code&gt;sqlite-utils&lt;/code&gt;!&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I made this change first &lt;a href="https://github.com/simonw/llm/issues/1016#issuecomment-2877305544"&gt;in LLM&lt;/a&gt; and decided to bring it to &lt;code&gt;sqlite-utils&lt;/code&gt; for consistency between the two tools.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&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;p&gt;One last minor ugliness that I waited for a major version bump to fix.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: Now that the embargo has lifted I can reveal that a substantial amount of the work on this release was performed using a preview version of Anthropic's &lt;a href="https://simonwillison.net/2025/Nov/24/claude-opus/"&gt;new Claude Opus 4.5 model&lt;/a&gt;. Here's the &lt;a href="https://gistpreview.github.io/?f40971b693024fbe984a68b73cc283d2"&gt;Claude Code transcript&lt;/a&gt; for the work to implement the ability to use an iterator over lists instead of dictionaries for bulk insert and upsert operations.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="annotated-release-notes"/><category term="coding-agents"/><category term="sqlite"/><category term="ai-assisted-programming"/><category term="projects"/><category term="sqlite-utils"/><category term="claude-code"/></entry><entry><title>llm-gemini 0.27</title><link href="https://simonwillison.net/2025/Nov/18/llm-gemini/#atom-tag" rel="alternate"/><published>2025-11-18T23:00:40+00:00</published><updated>2025-11-18T23:00:40+00:00</updated><id>https://simonwillison.net/2025/Nov/18/llm-gemini/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-gemini/releases/tag/0.27"&gt;llm-gemini 0.27&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New release of my LLM plugin for Google's Gemini models:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Support for nested schemas in Pydantic, thanks &lt;a href="https://github.com/billpugh"&gt;Bill Pugh&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm-gemini/pull/107"&gt;#107&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Now tests against Python 3.14.&lt;/li&gt;
&lt;li&gt;Support for YouTube URLs as attachments and the &lt;code&gt;media_resolution&lt;/code&gt; option. Thanks, &lt;a href="https://github.com/shuane"&gt;Duane Milne&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm-gemini/pull/112"&gt;#112&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New model: &lt;code&gt;gemini-3-pro-preview&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm-gemini/issues/113"&gt;#113&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The YouTube URL feature is particularly neat, taking advantage of &lt;a href="https://ai.google.dev/gemini-api/docs/video-understanding#youtube"&gt;this API feature&lt;/a&gt;. I used it against the &lt;a href="https://simonwillison.net/2025/Nov/18/google-antigravity/"&gt;Google Antigravity launch video&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -m gemini-3-pro-preview \
 -a 'https://www.youtube.com/watch?v=nTOVIGsqCuY' \
 'Summary, with detailed notes about what this thing is and how it differs from regular VS Code, then a complete detailed transcript with timestamps'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/9f30318ab47e0d177b4b523bb71d9540"&gt;the result&lt;/a&gt;. A spot-check of the timestamps against points in the video shows them to be exactly right.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/gemini"&gt;gemini&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/youtube"&gt;youtube&lt;/a&gt;&lt;/p&gt;



</summary><category term="gemini"/><category term="llm"/><category term="generative-ai"/><category term="projects"/><category term="ai"/><category term="llms"/><category term="youtube"/></entry><entry><title>llm-anthropic 0.22</title><link href="https://simonwillison.net/2025/Nov/15/llm-anthropic-022/#atom-tag" rel="alternate"/><published>2025-11-15T20:48:38+00:00</published><updated>2025-11-15T20:48:38+00:00</updated><id>https://simonwillison.net/2025/Nov/15/llm-anthropic-022/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-anthropic/releases/tag/0.22"&gt;llm-anthropic 0.22&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New release of my &lt;code&gt;llm-anthropic&lt;/code&gt; plugin:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Support for Claude's new &lt;a href="https://claude.com/blog/structured-outputs-on-the-claude-developer-platform"&gt;structured outputs&lt;/a&gt; feature for Sonnet 4.5 and Opus 4.1. &lt;a href="https://github.com/simonw/llm-anthropic/issues/54"&gt;#54&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Support for the &lt;a href="https://docs.claude.com/en/docs/agents-and-tools/tool-use/web-search-tool"&gt;web search tool&lt;/a&gt; using &lt;code&gt;-o web_search 1&lt;/code&gt; - thanks &lt;a href="https://github.com/nmpowell"&gt;Nick Powell&lt;/a&gt; and &lt;a href="https://github.com/statico"&gt;Ian Langworth&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm-anthropic/issues/30"&gt;#30&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The plugin previously powered &lt;a href="https://llm.datasette.io/en/stable/schemas.html"&gt;LLM schemas&lt;/a&gt; using &lt;a href="https://github.com/simonw/llm-anthropic/blob/0.22/llm_anthropic.py#L692-L700"&gt;this tool-call based workaround&lt;/a&gt;. That code is still used for Anthropic's older models.&lt;/p&gt;
&lt;p&gt;I also figured out &lt;code&gt;uv&lt;/code&gt; recipes for running the plugin's test suite in an isolated environment, which are now &lt;a href="https://github.com/simonw/llm-anthropic/blob/0.22/Justfile"&gt;baked into the new Justfile&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;&lt;/p&gt;



</summary><category term="llm"/><category term="anthropic"/><category term="claude"/><category term="generative-ai"/><category term="projects"/><category term="ai"/><category term="llms"/><category term="uv"/><category term="python"/></entry><entry><title>Datasette 1.0a22</title><link href="https://simonwillison.net/2025/Nov/13/datasette-10a22/#atom-tag" rel="alternate"/><published>2025-11-13T23:04:18+00:00</published><updated>2025-11-13T23:04:18+00:00</updated><id>https://simonwillison.net/2025/Nov/13/datasette-10a22/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a22-2025-11-13"&gt;Datasette 1.0a22&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New Datasette 1.0 alpha, adding some small features we needed to properly integrate the new permissions system with Datasette Cloud:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;datasette serve --default-deny&lt;/code&gt; option for running Datasette configured to &lt;a href="https://docs.datasette.io/en/latest/authentication.html#authentication-default-deny"&gt;deny all permissions by default&lt;/a&gt;. (&lt;a href="https://github.com/simonw/datasette/issues/2592"&gt;#2592&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;datasette.is_client()&lt;/code&gt; method for detecting if code is &lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-datasette-is-client"&gt;executing inside a datasette.client request&lt;/a&gt;. (&lt;a href="https://github.com/simonw/datasette/issues/2594"&gt;#2594&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Plus a developer experience improvement for plugin authors:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;datasette.pm&lt;/code&gt; property can now be used to &lt;a href="https://docs.datasette.io/en/latest/testing_plugins.html#testing-plugins-register-in-test"&gt;register and unregister plugins in tests&lt;/a&gt;. (&lt;a href="https://github.com/simonw/datasette/issues/2595"&gt;#2595&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-cloud"&gt;datasette-cloud&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="datasette-cloud"/><category term="annotated-release-notes"/><category term="datasette"/></entry><entry><title>A new SQL-powered permissions system in Datasette 1.0a20</title><link href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#atom-tag" rel="alternate"/><published>2025-11-04T21:34:42+00:00</published><updated>2025-11-04T21:34:42+00:00</updated><id>https://simonwillison.net/2025/Nov/4/datasette-10a20/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a20-2025-11-03"&gt;Datasette 1.0a20 is out&lt;/a&gt; with the biggest breaking API change on the road to 1.0, improving how Datasette's permissions system works by migrating permission logic to SQL running in SQLite. This release involved &lt;a href="https://github.com/simonw/datasette/compare/1.0a19...1.0a20"&gt;163 commits&lt;/a&gt;, with 10,660 additions and 1,825 deletions, most of which was written with the help of Claude Code.&lt;/p&gt;


&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#understanding-the-permissions-system"&gt;Understanding the permissions system&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#permissions-systems-need-to-be-able-to-efficiently-list-things"&gt;Permissions systems need to be able to efficiently list things&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#the-new-permission-resources-sql-plugin-hook"&gt;The new permission_resources_sql() plugin hook&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#hierarchies-plugins-vetoes-and-restrictions"&gt;Hierarchies, plugins, vetoes, and restrictions&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#new-debugging-tools"&gt;New debugging tools&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#the-missing-feature-list-actors-who-can-act-on-this-resource"&gt;The missing feature: list actors who can act on this resource&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#upgrading-plugins-for-datasette-1-0a20"&gt;Upgrading plugins for Datasette 1.0a20&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#using-claude-code-to-implement-this-change"&gt;Using Claude Code to implement this change&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#starting-with-a-proof-of-concept"&gt;Starting with a proof-of-concept&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#miscellaneous-tips-i-picked-up-along-the-way"&gt;Miscellaneous tips I picked up along the way&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Nov/4/datasette-10a20/#what-s-next-"&gt;What's next?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="understanding-the-permissions-system"&gt;Understanding the permissions system&lt;/h4&gt;
&lt;p&gt;Datasette's &lt;a href="https://docs.datasette.io/en/latest/authentication.html"&gt;permissions system&lt;/a&gt; exists to answer the following question:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Is this &lt;strong&gt;actor&lt;/strong&gt; allowed to perform this &lt;strong&gt;action&lt;/strong&gt;, optionally against this particular &lt;strong&gt;resource&lt;/strong&gt;?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;An &lt;strong&gt;actor&lt;/strong&gt; is usually a user, but might also be an automation operating via the Datasette API.&lt;/p&gt;
&lt;p&gt;An &lt;strong&gt;action&lt;/strong&gt; is a thing they need to do - things like view-table, execute-sql, insert-row.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;resource&lt;/strong&gt; is the subject of the action - the database you are executing SQL against, the table you want to insert a row into.&lt;/p&gt;
&lt;p&gt;Datasette's default configuration is public but read-only: anyone can view databases and tables or execute read-only SQL queries but no-one can modify data.&lt;/p&gt;
&lt;p&gt;Datasette plugins can enable all sorts of additional ways to interact with databases, many of which need to be protected by a form of authentication Datasette also 1.0 includes &lt;a href="https://simonwillison.net/2022/Dec/2/datasette-write-api/"&gt;a write API&lt;/a&gt; with a need to configure who can insert, update, and delete rows or create new tables.&lt;/p&gt;
&lt;p&gt;Actors can be authenticated in a number of different ways provided by plugins using the &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#actor-from-request-datasette-request"&gt;actor_from_request()&lt;/a&gt; plugin hook. &lt;a href="https://datasette.io/plugins/datasette-auth-passwords"&gt;datasette-auth-passwords&lt;/a&gt; and &lt;a href="https://datasette.io/plugins/datasette-auth-github"&gt;datasette-auth-github&lt;/a&gt; and &lt;a href="https://datasette.io/plugins/datasette-auth-existing-cookies"&gt;datasette-auth-existing-cookies&lt;/a&gt; are examples of authentication plugins.&lt;/p&gt;
&lt;h4 id="permissions-systems-need-to-be-able-to-efficiently-list-things"&gt;Permissions systems need to be able to efficiently list things&lt;/h4&gt;
&lt;p&gt;The previous implementation included a design flaw common to permissions systems of this nature: each permission check involved a function call which would delegate to one or more plugins and return a True/False result.&lt;/p&gt;
&lt;p&gt;This works well for single checks, but has a significant problem: what if you need to show the user a list of things they can access, for example the tables they can view?&lt;/p&gt;
&lt;p&gt;I want Datasette to be able to handle potentially thousands of tables - tables in SQLite are cheap! I don't want to have to run 1,000+ permission checks just to show the user a list of tables.&lt;/p&gt;
&lt;p&gt;Since Datasette is built on top of SQLite we already have a powerful mechanism to help solve this problem. SQLite is &lt;em&gt;really&lt;/em&gt; good at filtering large numbers of records.&lt;/p&gt;
&lt;h4 id="the-new-permission-resources-sql-plugin-hook"&gt;The new permission_resources_sql() plugin hook&lt;/h4&gt;
&lt;p&gt;The biggest change in the new release is that I've replaced the previous  &lt;code&gt;permission_allowed(actor, action, resource)&lt;/code&gt; plugin hook - which let a plugin determine if an actor could perform an action against a resource - with a new &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#plugin-hook-permission-resources-sql"&gt;permission_resources_sql(actor, action)&lt;/a&gt; plugin hook.&lt;/p&gt;
&lt;p&gt;Instead of returning a True/False result, this new hook returns a SQL query that returns rules helping determine the resources the current actor can execute the specified action against.&lt;/p&gt;
&lt;p&gt;Here's an example, lifted from the documentation:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;hookimpl&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt;.&lt;span class="pl-s1"&gt;permissions&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;PermissionSQL&lt;/span&gt;


&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;hookimpl&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;permission_resources_sql&lt;/span&gt;(&lt;span class="pl-s1"&gt;datasette&lt;/span&gt;, &lt;span class="pl-s1"&gt;actor&lt;/span&gt;, &lt;span class="pl-s1"&gt;action&lt;/span&gt;):
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;action&lt;/span&gt; &lt;span class="pl-c1"&gt;!=&lt;/span&gt; &lt;span class="pl-s"&gt;"view-table"&lt;/span&gt;:
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-c1"&gt;not&lt;/span&gt; &lt;span class="pl-s1"&gt;actor&lt;/span&gt; &lt;span class="pl-c1"&gt;or&lt;/span&gt; &lt;span class="pl-s1"&gt;actor&lt;/span&gt;.&lt;span class="pl-c1"&gt;get&lt;/span&gt;(&lt;span class="pl-s"&gt;"id"&lt;/span&gt;) &lt;span class="pl-c1"&gt;!=&lt;/span&gt; &lt;span class="pl-s"&gt;"alice"&lt;/span&gt;:
        &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-c1"&gt;None&lt;/span&gt;

    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-en"&gt;PermissionSQL&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;sql&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"""&lt;/span&gt;
&lt;span class="pl-s"&gt;            SELECT&lt;/span&gt;
&lt;span class="pl-s"&gt;                'accounting' AS parent,&lt;/span&gt;
&lt;span class="pl-s"&gt;                'sales' AS child,&lt;/span&gt;
&lt;span class="pl-s"&gt;                1 AS allow,&lt;/span&gt;
&lt;span class="pl-s"&gt;                'alice can view accounting/sales' AS reason&lt;/span&gt;
&lt;span class="pl-s"&gt;        """&lt;/span&gt;,
    )&lt;/pre&gt;
&lt;p&gt;This hook grants the actor with ID "alice" permission to view the "sales" table in the "accounting" database.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;PermissionSQL&lt;/code&gt; object should always return four columns: a parent, child, allow (1 or 0), and a reason string for debugging.&lt;/p&gt;
&lt;p&gt;When you ask Datasette to list the resources an actor can access for a specific action, it will combine the SQL returned by all installed plugins into a single query that joins against &lt;a href="https://docs.datasette.io/en/latest/internals.html#internal-database-schema"&gt;the internal catalog tables&lt;/a&gt; and efficiently lists all the resources the actor can access.&lt;/p&gt;
&lt;p&gt;This query can then be limited or paginated to avoid loading too many results at once.&lt;/p&gt;
&lt;h4 id="hierarchies-plugins-vetoes-and-restrictions"&gt;Hierarchies, plugins, vetoes, and restrictions&lt;/h4&gt;
&lt;p&gt;Datasette has several additional requirements that make the permissions system more complicated.&lt;/p&gt;
&lt;p&gt;Datasette permissions can optionally act against a two-level &lt;strong&gt;hierarchy&lt;/strong&gt;. You can grant a user the ability to insert-row against a specific table, or every table in a specific database, or every table in &lt;em&gt;every&lt;/em&gt; database in that Datasette instance.&lt;/p&gt;
&lt;p&gt;Some actions can apply at the table level, others the database level and others only make sense globally - enabling a new feature that isn't tied to tables or databases, for example.&lt;/p&gt;
&lt;p&gt;Datasette currently has &lt;a href="https://docs.datasette.io/en/latest/authentication.html#built-in-actions"&gt;ten default actions&lt;/a&gt; but &lt;strong&gt;plugins&lt;/strong&gt; that add additional features can &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#register-actions-datasette"&gt;register new actions&lt;/a&gt; to better participate in the permission systems.&lt;/p&gt;
&lt;p&gt;Datasette's permission system has a mechanism to &lt;strong&gt;veto&lt;/strong&gt; permission checks - a plugin can return a deny for a specific permission check which will override any allows. This needs to be hierarchy-aware - a deny at the database level can be outvoted by an allow at the table level.&lt;/p&gt;
&lt;p&gt;Finally, Datasette includes a mechanism for applying additional &lt;strong&gt;restrictions&lt;/strong&gt; to a request. This was introduced for Datasette's API - it allows a user to create an API token that can act on their behalf but is only allowed to perform a subset of their capabilities - just reading from two specific tables, for example. Restrictions are &lt;a href="https://docs.datasette.io/en/latest/authentication.html#restricting-the-actions-that-a-token-can-perform"&gt;described in more detail&lt;/a&gt; in the documentation.&lt;/p&gt;
&lt;p&gt;That's a lot of different moving parts for the new implementation to cover.&lt;/p&gt;
&lt;h4 id="new-debugging-tools"&gt;New debugging tools&lt;/h4&gt;
&lt;p&gt;Since permissions are critical to the security of a Datasette deployment it's vital that they are as easy to understand and debug as possible.&lt;/p&gt;
&lt;p&gt;The new alpha adds several new debugging tools, including this page that shows the full list of resources matching a specific action for the current user:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/datasette-allowed-resources.jpg" alt="Allowed resources. Tabs are Playground, Check, Allowed, Rules, Actions, Allow debug. There is a form where you can select an action (here view-table) and optionally filter by parent and child. Below is a table of results listing resource paths - e.g. /fixtures/name-of-table - plus parent, child and reason columns. The reason is a JSON list for example &amp;quot;datasette.default_permissions: root user&amp;quot;,&amp;quot;datasette.default_permissions: default allow for view-table&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;And this page listing the &lt;em&gt;rules&lt;/em&gt; that apply to that question - since different plugins may return different rules which get combined together:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/datasette-rules.jpg" alt="The rules tab for the same view-table question. Here there are two allow rules - one from datasette.default_permissions for the root user and another from default_permissions labelled default allow for view-table." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This screenshot illustrates two of Datasette's built-in rules: there is a default allow for read-only operations such as view-table (which can be over-ridden by plugins) and another rule that says the root user can do anything (provided Datasette was started with the &lt;code&gt;--root&lt;/code&gt; option.)&lt;/p&gt;
&lt;p&gt;Those rules are defined in the &lt;a href="https://github.com/simonw/datasette/blob/1.0a20/datasette/default_permissions.py"&gt;datasette/default_permissions.py&lt;/a&gt; Python module.&lt;/p&gt;
&lt;h4 id="the-missing-feature-list-actors-who-can-act-on-this-resource"&gt;The missing feature: list actors who can act on this resource&lt;/h4&gt;
&lt;p&gt;There's one question that the new system cannot answer: provide a full list of actors who can perform this action against this resource.&lt;/p&gt;
&lt;p&gt;It's not possibly to provide this globally for Datasette because Datasette doesn't have a way to track what "actors" exist in the system. SSO plugins such as &lt;code&gt;datasette-auth-github&lt;/code&gt; mean a new authenticated GitHub user might show up at any time, with the ability to perform actions despite the Datasette system never having encountered that particular username before.&lt;/p&gt;
&lt;p&gt;API tokens and actor restrictions come into play here as well. A user might create a signed API token that can perform a subset of actions on their behalf - the existence of that token can't be predicted by the permissions system.&lt;/p&gt;
&lt;p&gt;This is a notable omission, but it's also quite common in other systems. AWS cannot provide a list of all actors who have permission to access a specific S3 bucket, for example - presumably for similar reasons.&lt;/p&gt;
&lt;h4 id="upgrading-plugins-for-datasette-1-0a20"&gt;Upgrading plugins for Datasette 1.0a20&lt;/h4&gt;
&lt;p&gt;Datasette's plugin ecosystem is the reason I'm paying so much attention to ensuring Datasette 1.0 has a stable API. I don't want plugin authors to need to chase breaking changes once that 1.0 release is out.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://docs.datasette.io/en/latest/upgrade_guide.html"&gt;Datasette upgrade guide&lt;/a&gt; includes detailed notes on upgrades that are needed between the 0.x and 1.0 alpha releases. I've added an extensive section about the permissions changes to that document.&lt;/p&gt;
&lt;p&gt;I've also been experimenting with dumping those instructions directly into coding agent tools - Claude Code and Codex CLI - to have them upgrade existing plugins for me. This has been working &lt;em&gt;extremely well&lt;/em&gt;. I've even had Claude Code &lt;a href="https://github.com/simonw/datasette/commit/fa978ec1006297416e2cd87a2f0d3cac99283cf8"&gt;update those notes itself&lt;/a&gt; with things it learned during an upgrade process!&lt;/p&gt;
&lt;p&gt;This is greatly helped by the fact that every single Datasette plugin has an automated test suite that demonstrates the core functionality works as expected. Coding agents can use those tests to verify that their changes have had the desired effect.&lt;/p&gt;
&lt;p&gt;I've also been leaning heavily on &lt;code&gt;uv&lt;/code&gt; to help with the upgrade process. I wrote myself two new helper scripts - &lt;code&gt;tadd&lt;/code&gt; and &lt;code&gt;radd&lt;/code&gt; - to help test the new plugins.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tadd&lt;/code&gt; = "test against datasette dev" - it runs a plugin's existing test suite against the current development version of Datasette checked out on my machine. It passes extra options through to &lt;code&gt;pytest&lt;/code&gt; so I can run &lt;code&gt;tadd -k test_name&lt;/code&gt; or &lt;code&gt;tadd -x --pdb&lt;/code&gt; as needed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;radd&lt;/code&gt; = "run against datasette dev" - it runs the latest dev &lt;code&gt;datasette&lt;/code&gt; command with the plugin installed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;tadd&lt;/code&gt; and &lt;code&gt;radd&lt;/code&gt; implementations &lt;a href="https://til.simonwillison.net/python/uv-tests#variants-tadd-and-radd"&gt;can be found in this TIL&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Some of my plugin upgrades have become a one-liner to the &lt;code&gt;codex exec&lt;/code&gt; command, which runs OpenAI Codex CLI with a prompt without entering interactive mode:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;codex &lt;span class="pl-c1"&gt;exec&lt;/span&gt; --dangerously-bypass-approvals-and-sandbox \
&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Run the command tadd and look at the errors and then&lt;/span&gt;
&lt;span class="pl-s"&gt;read ~/dev/datasette/docs/upgrade-1.0a20.md and apply&lt;/span&gt;
&lt;span class="pl-s"&gt;fixes and run the tests again and get them to pass&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;There are still a bunch more to go - there's &lt;a href="https://github.com/simonw/datasette/issues/2577"&gt;a list in this tracking issue&lt;/a&gt; - but I expect to have the plugins I maintain all upgraded pretty quickly now that I have a solid process in place.&lt;/p&gt;
&lt;h4 id="using-claude-code-to-implement-this-change"&gt;Using Claude Code to implement this change&lt;/h4&gt;
&lt;p&gt;This change to Datasette core &lt;em&gt;by far&lt;/em&gt; the most ambitious piece of work I've ever attempted using a coding agent.&lt;/p&gt;
&lt;p&gt;Last year I agreed with the prevailing opinion that LLM assistance was much more useful for greenfield coding tasks than working on existing codebases. The amount you could usefully get done was greatly limited by the need to fit the entire codebase into the model's context window.&lt;/p&gt;
&lt;p&gt;Coding agents have entirely changed that calculation. Claude Code and Codex CLI still have relatively limited token windows - albeit larger than last year - but their ability to search through the codebase, read extra files on demand and "reason" about the code they are working with has made them vastly more capable.&lt;/p&gt;
&lt;p&gt;I no longer see codebase size as a limiting factor for how useful they can be.&lt;/p&gt;
&lt;p&gt;I've also spent enough time with Claude Sonnet 4.5 to build a weird level of trust in it. I can usually predict exactly what changes it will make for a prompt. If I tell it "extract this code into a separate function" or "update every instance of this pattern" I know it's likely to get it right.&lt;/p&gt;
&lt;p&gt;For something like permission code I still review everything it does, often by watching it as it works since it displays diffs in the UI.&lt;/p&gt;
&lt;p&gt;I also pay extremely close attention to the tests it's writing. Datasette 1.0a19 already had 1,439 tests, many of which exercised the existing permission system. 1.0a20 increases that to 1,583 tests. I feel very good about that, especially since most of the existing tests continued to pass without modification.&lt;/p&gt;
&lt;h4 id="starting-with-a-proof-of-concept"&gt;Starting with a proof-of-concept&lt;/h4&gt;
&lt;p&gt;I built several different proof-of-concept implementations of SQL permissions before settling on the final design. My &lt;a href="https://github.com/simonw/research/tree/main/sqlite-permissions-poc"&gt;research/sqlite-permissions-poc&lt;/a&gt; project was the one that finally convinced me of a viable approach,&lt;/p&gt;
&lt;p&gt;That one started as a &lt;a href="https://claude.ai/share/8fd432bc-a718-4883-9978-80ab82a75c87"&gt;free ranging conversation with Claude&lt;/a&gt;, at the end of which I told it to generate a specification which I then &lt;a href="https://chatgpt.com/share/68f6532f-9920-8006-928a-364e15b6e9ef"&gt;fed into GPT-5&lt;/a&gt; to implement. You can see that specification &lt;a href="https://github.com/simonw/research/tree/main/sqlite-permissions-poc#original-prompt"&gt;at the end of the README&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I later fed the POC itself into Claude Code and had it implement the first version of the new Datasette system based on that previous experiment.&lt;/p&gt;
&lt;p&gt;This is admittedly a very weird way of working, but it helped me finally break through on a problem that I'd been struggling with for months.&lt;/p&gt;
&lt;h4 id="miscellaneous-tips-i-picked-up-along-the-way"&gt;Miscellaneous tips I picked up along the way&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;When working on anything relating to plugins it's vital to have at least a few real plugins that you upgrade in lock-step with the core changes. The &lt;code&gt;tadd&lt;/code&gt; and &lt;code&gt;radd&lt;/code&gt; shortcuts were invaluable for productively working on those plugins while I made changes to core.&lt;/li&gt;
&lt;li&gt;Coding agents make experiments &lt;em&gt;much&lt;/em&gt; cheaper. I threw away so much code on the way to the final implementation, which was psychologically easier because the cost to create that code in the first place was so low.&lt;/li&gt;
&lt;li&gt;Tests, tests, tests. This project would have been impossible without that existing test suite. The additional tests we built along the way give me confidence that the new system is as robust as I need it to be.&lt;/li&gt;
&lt;li&gt;Claude writes good commit messages now! I finally gave in and let it write these - previously I've been determined to write them myself. It's a big time saver to be able to say "write a tasteful commit message for these changes".&lt;/li&gt;
&lt;li&gt;Claude is also great at breaking up changes into smaller commits. It can also productively rewrite history to make it easier to follow, especially useful if you're still working in a branch.&lt;/li&gt;
&lt;li&gt;A really great way to review Claude's changes is with the GitHub PR interface. You can attach comments to individual lines of code and then later prompt Claude like this: &lt;code&gt;Use gh CLI to fetch comments on URL-to-PR and make the requested changes&lt;/code&gt;. This is a very quick way to apply little nitpick changes - rename this function, refactor this repeated code, add types here etc.&lt;/li&gt;
&lt;li&gt;The code I write with LLMs is &lt;em&gt;higher quality code&lt;/em&gt;. I usually find myself making constant trade-offs while coding: this function would be neater if I extracted this helper, it would be nice to have inline documentation here, this changing this would be good but would break a dozen tests... for each of those I have to determine if the additional time is worth the benefit. Claude can apply changes so much faster than me that these calculations have changed - almost any improvement is worth applying, no matter how trivial, because the time cost is so low.&lt;/li&gt;
&lt;li&gt;Internal tools are cheap now. The new debugging interfaces were mostly written by Claude and are significantly nicer to use and look at than the hacky versions I would have knocked out myself, if I had even taken the extra time to build them.&lt;/li&gt;
&lt;li&gt;That trick with a Markdown file full of upgrade instructions works astonishingly well - it's the same basic idea as &lt;a href="https://simonwillison.net/2025/Oct/16/claude-skills/"&gt;Claude Skills&lt;/a&gt;. I maintain over 100 Datasette plugins now and I expect I'll be automating all sorts of minor upgrades in the future using this technique.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="what-s-next-"&gt;What's next?&lt;/h4&gt;
&lt;p&gt;Now that the new alpha is out my focus is upgrading the existing plugin ecosystem to use it, and supporting other plugin authors who are doing the same.&lt;/p&gt;
&lt;p&gt;The new permissions system unlocks some key improvements to Datasette Cloud concerning finely-grained permissions for larger teams, so I'll be integrating the new alpha there this week.&lt;/p&gt;
&lt;p&gt;This is the single biggest backwards-incompatible change required before Datasette 1.0. I plan to apply the lessons I learned from this project to the other, less intimidating changes. I'm hoping this can result in a final 1.0 release before the end of the year!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex-cli"&gt;codex-cli&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sql"/><category term="sqlite"/><category term="plugins"/><category term="uv"/><category term="annotated-release-notes"/><category term="claude-code"/><category term="codex-cli"/><category term="coding-agents"/><category term="python"/><category term="datasette"/><category term="projects"/></entry><entry><title>SLOCCount in WebAssembly</title><link href="https://simonwillison.net/2025/Oct/22/sloccount-in-webassembly/#atom-tag" rel="alternate"/><published>2025-10-22T06:12:25+00:00</published><updated>2025-10-22T06:12:25+00:00</updated><id>https://simonwillison.net/2025/Oct/22/sloccount-in-webassembly/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tools.simonwillison.net/sloccount"&gt;SLOCCount in WebAssembly&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This project/side-quest got a little bit out of hand.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of SLOCCount web application showing code analysis interface. The page header reads &amp;quot;SLOCCount - Count Lines of Code&amp;quot; with subtitle &amp;quot;Analyze source code to count physical Source Lines of Code (SLOC) using Perl and C programs running via WebAssembly&amp;quot; and &amp;quot;Based on SLOCCount by David A. Wheeler&amp;quot;. Three tabs are shown: &amp;quot;Paste Code&amp;quot;, &amp;quot;GitHub Repository&amp;quot; (selected), and &amp;quot;Upload ZIP&amp;quot;. Below is a text input field labeled &amp;quot;GitHub Repository URL:&amp;quot; containing &amp;quot;simonw/llm&amp;quot; and a blue &amp;quot;Analyze Repository&amp;quot; button. The Analysis Results section displays five statistics: Total Lines: 13,490, Languages: 2, Files: 40, Est. Cost (USD)*: $415,101, and Est. Person-Years*: 3.07." src="https://static.simonwillison.net/static/2025/sloccount.jpg" class="blogmark-image" style="max-width: 95%;"&gt;&lt;/p&gt;
&lt;p&gt;I remembered an old tool called SLOCCount which could count lines of code and produce an estimate for how much they would cost to develop. I thought it would be fun to play around with it again, especially given how cheap it is to generate code using LLMs these days.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://dwheeler.com/sloccount/"&gt;the homepage for SLOCCount&lt;/a&gt; by David A. Wheeler. It dates back to 2001!&lt;/p&gt;
&lt;p&gt;I figured it might be fun to try and get it running on the web. Surely someone had compiled Perl to WebAssembly...?&lt;/p&gt;
&lt;p&gt;&lt;a href="https://webperl.zero-g.net"&gt;WebPerl&lt;/a&gt; by Hauke Dämpfling is exactly that, even adding a neat &lt;code&gt;&amp;lt;script type="text/perl"&amp;gt;&lt;/code&gt; tag.&lt;/p&gt;
&lt;p&gt;I told Claude Code for web on my iPhone to figure it out and build something, giving it some hints from my initial research:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Build sloccount.html - a mobile friendly UI for running the Perl sloccount tool against pasted code or against a GitHub repository that is provided in a form field&lt;/p&gt;
&lt;p&gt;It works using the webperl webassembly build of Perl, plus it loads Perl code from this exact commit of this GitHub repository https://github.com/licquia/sloccount/tree/7220ff627334a8f646617fe0fa542d401fb5287e - I guess via the GitHub API, maybe using the https://github.com/licquia/sloccount/archive/7220ff627334a8f646617fe0fa542d401fb5287e.zip URL if that works via CORS&lt;/p&gt;
&lt;p&gt;Test it with playwright Python - don’t edit any file other than sloccount.html and a tests/test_sloccount.py file&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Since I was working on my phone I didn't review the results at all. It seemed to work so I deployed it to static hosting... and then when I went to look at it properly later on found that Claude had given up, cheated and reimplemented it in JavaScript instead!&lt;/p&gt;
&lt;p&gt;So I switched to Claude Code on my laptop where I have more control and coached Claude through implementing the project for real. This took &lt;em&gt;way longer&lt;/em&gt; than the project deserved - probably a solid hour of my active time, spread out across the morning.&lt;/p&gt;
&lt;p&gt;I've shared some of the transcripts - &lt;a href="https://gistpreview.github.io/?0fc406a18e14a1f7d28bfff02a18eaaf#simonw/0fc406a18e14a1f7d28bfff02a18eaaf"&gt;one&lt;/a&gt;, &lt;a href="https://gistpreview.github.io/?56ecae45cf2e1baca798a83deea50939"&gt;two&lt;/a&gt;, and &lt;a href="https://gistpreview.github.io/?79ca231e801fe1188268a54d30aa67ed"&gt;three&lt;/a&gt; - as terminal sessions rendered to HTML using my &lt;a href="https://tools.simonwillison.net/rtf-to-html"&gt;rtf-to-html&lt;/a&gt; tool.&lt;/p&gt;
&lt;p&gt;At one point I realized that the original SLOCCount project wasn't even entirely Perl as I had assumed, it included several C utilities! So I had Claude Code figure out how to compile those to WebAssembly (it used Emscripten) and incorporate those into the project (with &lt;a href="https://github.com/simonw/tools/blob/473e89edfebc27781b434430f2e8a76adfbe3b16/lib/README.md#webassembly-compilation-of-c-programs"&gt;notes on what it did&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;The end result (&lt;a href="https://github.com/simonw/tools/blob/main/sloccount.html"&gt;source code here&lt;/a&gt;) is actually pretty cool. It's a web UI with three tabs - one for pasting in code, a second for loading code from a GitHub repository and a third that lets you open a Zip file full of code that you want to analyze. Here's an animated demo:&lt;/p&gt;
&lt;p&gt;&lt;img alt="I enter simonw/llm in the GitHub repository field. It loads 41 files from GitHub and displays a report showing the number of lines and estimated cost." src="https://static.simonwillison.net/static/2025/sloccount-optimized.gif" /&gt;&lt;/p&gt;
&lt;p&gt;The cost estimates it produces are of very little value. By default it uses the original method from 2001. You can also twiddle the factors - bumping up the expected US software engineer's annual salary from its 2000 estimate of $56,286 is a good start! &lt;/p&gt;
&lt;p&gt;I had ChatGPT &lt;a href="https://chatgpt.com/share/68f7e0ac-00c4-8006-979e-64d1f0162283"&gt;take a guess&lt;/a&gt; at what those figures should be for today and included those in the tool, with a &lt;strong&gt;very&lt;/strong&gt; prominent warning not to trust them in the slightest.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/perl"&gt;perl&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;&lt;/p&gt;



</summary><category term="perl"/><category term="ai"/><category term="claude-code"/><category term="llms"/><category term="vibe-coding"/><category term="webassembly"/><category term="ai-assisted-programming"/><category term="tools"/><category term="javascript"/><category term="generative-ai"/><category term="projects"/></entry><entry><title>Claude can write complete Datasette plugins now</title><link href="https://simonwillison.net/2025/Oct/8/claude-datasette-plugins/#atom-tag" rel="alternate"/><published>2025-10-08T23:43:43+00:00</published><updated>2025-10-08T23:43:43+00:00</updated><id>https://simonwillison.net/2025/Oct/8/claude-datasette-plugins/#atom-tag</id><summary type="html">
    &lt;p&gt;This isn't necessarily surprising, but it's worth noting anyway. Claude Sonnet 4.5 is capable of building a full Datasette plugin now.&lt;/p&gt;
&lt;p&gt;I've seen models complete aspects of this in the past, but today is the first time I've shipped a new plugin where every line of code and test was written by Claude, with minimal prompting from myself.&lt;/p&gt;
&lt;p&gt;The plugin is called &lt;strong&gt;&lt;a href="https://github.com/datasette/datasette-os-info"&gt;datasette-os-info&lt;/a&gt;&lt;/strong&gt;. It's a simple debugging tool - all it does is add a &lt;code&gt;/-/os&lt;/code&gt; JSON page which dumps out as much information as it can about the OS it's running on. Here's a &lt;a href="https://til.simonwillison.net/-/os"&gt;live demo&lt;/a&gt; on my TIL website.&lt;/p&gt;
&lt;p&gt;I built it to help experiment with changing the Docker base container that Datasette uses to &lt;a href="https://docs.datasette.io/en/stable/publish.html"&gt;publish images&lt;/a&gt; to one that uses Python 3.14.&lt;/p&gt;
&lt;p&gt;Here's the full set of commands I used to create the plugin. I started with my &lt;a href="https://github.com/simonw/datasette-plugin"&gt;datasette-plugin&lt;/a&gt; cookiecutter template:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx cookiecutter gh:simonw/datasette-plugin

  [1/8] &lt;span class="pl-en"&gt;plugin_name&lt;/span&gt; (): os-info
  [2/8] &lt;span class="pl-en"&gt;description&lt;/span&gt; (): Information about the current OS
  [3/8] hyphenated (os-info): 
  [4/8] underscored (os_info): 
  [5/8] &lt;span class="pl-en"&gt;github_username&lt;/span&gt; (): datasette
  [6/8] &lt;span class="pl-en"&gt;author_name&lt;/span&gt; (): Simon Willison
  [7/8] &lt;span class="pl-en"&gt;include_static_directory&lt;/span&gt; (): 
  [8/8] &lt;span class="pl-en"&gt;include_templates_directory&lt;/span&gt; (): &lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This created a &lt;code&gt;datasette-os-info&lt;/code&gt; directory with the initial &lt;code&gt;pyproject.toml&lt;/code&gt; and &lt;code&gt;tests/&lt;/code&gt; and &lt;code&gt;datasette_os_info/__init__.py&lt;/code&gt; files. Here's an example of &lt;a href="https://github.com/simonw/datasette-plugin-template-demo"&gt;that starter template&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I created a &lt;code&gt;uv&lt;/code&gt; virtual environment for it, installed the initial test dependencies and ran &lt;code&gt;pytest&lt;/code&gt; to check that worked:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;cd&lt;/span&gt; datasette-os-info
uv venv
uv sync --extra &lt;span class="pl-c1"&gt;test&lt;/span&gt;
uv run pytest&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then I fired up &lt;a href="https://www.claude.com/product/claude-code"&gt;Claude Code&lt;/a&gt; in that directory in YOLO mode:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;claude --dangerously-skip-permissions&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(I actually used my &lt;code&gt;claude-yolo&lt;/code&gt; shortcut which runs the above.)&lt;/p&gt;
&lt;p&gt;Then, in Claude, I told it how to run the tests:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Run uv run pytest&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When that worked, I told it to build the plugin:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;This is a Datasette plugin which should add a new page /-/os which returns pretty-printed JSON about the current operating system - implement it. I want to pick up as many details as possible across as many OS as possible, including if possible figuring out the base image if it is in a docker container - otherwise the Debian OS release name and suchlike would be good&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;... and that was it! Claude &lt;a href="https://github.com/datasette/datasette-os-info/blob/0.1/datasette_os_info/__init__.py"&gt;implemented the plugin&lt;/a&gt; using Datasette's &lt;a href="https://docs.datasette.io/en/stable/plugin_hooks.html#register-routes-datasette"&gt;register_routes() plugin hook&lt;/a&gt; to add the &lt;code&gt;/-/os&lt;/code&gt; page,and then without me prompting it to do so &lt;a href="https://github.com/datasette/datasette-os-info/blob/0.1/tests/test_os_info.py"&gt;built this basic test as well&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It ran the new test, spotted a bug (it had guessed a non-existent &lt;code&gt;Response(..., default_repr=)&lt;/code&gt; parameter), fixed the bug and declared itself done.&lt;/p&gt;
&lt;p&gt;I built myself a wheel:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv pip install build
uv run python -m build&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then uploaded that to an S3 bucket and deployed it to test it out using &lt;code&gt;datasette publish ... --install URL-to-wheel&lt;/code&gt;.  It did exactly what I had hoped - here's what that &lt;code&gt;/-/os&lt;/code&gt; page looked like:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"platform"&lt;/span&gt;: {
    &lt;span class="pl-ent"&gt;"system"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Linux&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"release"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;4.4.0&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"version"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#1 SMP Sun Jan 10 15:06:54 PST 2016&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"machine"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;x86_64&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"processor"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"architecture"&lt;/span&gt;: [
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;64bit&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;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    ],
    &lt;span class="pl-ent"&gt;"platform"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Linux-4.4.0-x86_64-with-glibc2.41&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"python_version"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;3.14.0&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"python_implementation"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;CPython&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  },
  &lt;span class="pl-ent"&gt;"hostname"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;localhost&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"cpu_count"&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"linux"&lt;/span&gt;: {
    &lt;span class="pl-ent"&gt;"os_release"&lt;/span&gt;: {
      &lt;span class="pl-ent"&gt;"PRETTY_NAME"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Debian GNU/Linux 13 (trixie)&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"NAME"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Debian GNU/Linux&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"VERSION_ID"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;13&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"VERSION"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;13 (trixie)&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"VERSION_CODENAME"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;trixie&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"DEBIAN_VERSION_FULL"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;13.1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"ID"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;debian&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"HOME_URL"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://www.debian.org/&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"SUPPORT_URL"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://www.debian.org/support&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"BUG_REPORT_URL"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://bugs.debian.org/&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    },
    &lt;span class="pl-ent"&gt;"debian_version"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;13.1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"kernel"&lt;/span&gt;: {
      &lt;span class="pl-ent"&gt;"sysname"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Linux&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"nodename"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;localhost&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"release"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;4.4.0&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"version"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;#1 SMP Sun Jan 10 15:06:54 PST 2016&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"machine"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;x86_64&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    },
    &lt;span class="pl-ent"&gt;"cpu_model"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;unknown&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"memory"&lt;/span&gt;: {
      &lt;span class="pl-ent"&gt;"MemTotal"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;1048576 kB&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"MemFree"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;958244 kB&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"MemAvailable"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;958244 kB&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"SwapTotal"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;0 kB&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"SwapFree"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;0 kB&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    }
  },
  &lt;span class="pl-ent"&gt;"environment"&lt;/span&gt;: {
    &lt;span class="pl-ent"&gt;"HOME"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;/root&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"PATH"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  },
  &lt;span class="pl-ent"&gt;"python_executable"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;/usr/local/bin/python3.14&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I dread to think how long it would have taken me to figure out all of those different useful OS details!&lt;/p&gt;
&lt;p&gt;To finish up, I had Claude fill out the README via a couple of extra prompts:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Add usage instructions to the README (about the /-/os page)&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then had a brief conversation about how safe the output was:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Is there anything potentially sensitive in that output?&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Claude said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Yes, there are several potentially sensitive items in the output:&lt;/p&gt;
&lt;p&gt;Most sensitive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;hostname - Could reveal internal naming schemes or organization details&lt;/li&gt;
&lt;li&gt;Environment variables:
&lt;ul&gt;
&lt;li&gt;HOME - Reveals user's home directory path&lt;/li&gt;
&lt;li&gt;USER - Reveals the username&lt;/li&gt;
&lt;li&gt;PATH - Reveals filesystem structure and software installation locations&lt;/li&gt;
&lt;li&gt;VIRTUAL_ENV - Could reveal project paths and directory structure&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;python_executable - Reveals filesystem paths and user directories&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Moderately sensitive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CPU count and memory info - Could reveal infrastructure capacity&lt;/li&gt;
&lt;li&gt;Kernel version details - Could potentially be used to identify specific vulnerabilities&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Considerations:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;This endpoint is accessible without authentication by default (at /-/os)&lt;/li&gt;
&lt;li&gt;If you're running Datasette on a public server, this information would be visible to anyone&lt;/li&gt;
&lt;li&gt;The information could help attackers understand your infrastructure setup&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Suggestions:&lt;/p&gt;
&lt;p&gt;You might want to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add authentication requirements to this endpoint&lt;/li&gt;
&lt;li&gt;Make certain fields optional/configurable&lt;/li&gt;
&lt;li&gt;Add a warning in the README that this exposes system information&lt;/li&gt;
&lt;li&gt;Consider filtering out or redacting sensitive paths (replace usernames with ***, etc.)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Would you like me to add any of these protections to the plugin?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In this case I'm fine with the current behavior provided people understand the risks, so I told it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Add the warning to the README&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And then (because the warning it added was quite long):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Drop the bit about configuring the permission system, just have a short warning telling people to review what it exposes&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And requested an extra README note:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Add a note that you can also see the output by running: datasette --get /-/os&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Three last prompts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Add uv.lock to gitignore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Drop Python 3.9 and add Python 3.14 - to the GitHub workflows, also min version in pyproject.toml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Bump to setup-python@v6&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;... and that was the project finished. I pushed it &lt;a href="https://github.com/datasette/datasette-os-info"&gt;to GitHub&lt;/a&gt;, &lt;a href="https://til.simonwillison.net/pypi/pypi-releases-from-github"&gt;configured Trusted Publishing&lt;/a&gt; for it on PyPI and posted &lt;a href="https://github.com/datasette/datasette-os-info/releases/tag/0.1"&gt;the 0.1 release&lt;/a&gt;, which ran &lt;a href="https://github.com/datasette/datasette-os-info/blob/0.1/.github/workflows/publish.yml"&gt;this GitHub Actions publish.yml&lt;/a&gt; and deployed that release &lt;a href="https://pypi.org/project/datasette-os-info/"&gt;to datasette-os-info on PyPI&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Now that it's live you can try it out without even installing Datasette using a &lt;code&gt;uv&lt;/code&gt; one-liner like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --isolated \
  --with datasette-os-info \
  datasette --get /-/os&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That's using the &lt;code&gt;--get PATH&lt;/code&gt; CLI option to show what that path in the Datasette instance would return, as &lt;a href="https://docs.datasette.io/en/stable/cli-reference.html#datasette-get"&gt;described in the Datasette documentation&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I've shared &lt;a href="https://gist.github.com/simonw/85fd7a76589dc01950e71d8e606cd5dd"&gt;my full Claude Code transcript&lt;/a&gt; in a Gist.&lt;/p&gt;
&lt;p&gt;A year ago I'd have been &lt;em&gt;very&lt;/em&gt; impressed by this. Today I wasn't even particularly surprised that this worked - the coding agent pattern implemented by Claude Code is spectacularly effective when you combine it with pre-existing templates, and Datasette has been aroung for long enough now that plenty of examples of plugins have made it into the training data for the leading models.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="anthropic"/><category term="claude"/><category term="plugins"/><category term="uv"/><category term="ai"/><category term="claude-code"/><category term="llms"/><category term="datasette"/><category term="generative-ai"/><category term="projects"/><category term="coding-agents"/><category term="ai-assisted-programming"/><category term="python"/></entry><entry><title>llm-openrouter 0.5</title><link href="https://simonwillison.net/2025/Sep/21/llm-openrouter/#atom-tag" rel="alternate"/><published>2025-09-21T00:24:05+00:00</published><updated>2025-09-21T00:24:05+00:00</updated><id>https://simonwillison.net/2025/Sep/21/llm-openrouter/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-openrouter/releases/tag/0.5"&gt;llm-openrouter 0.5&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New release of my &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; plugin for accessing models made available via &lt;a href="https://openrouter.ai/"&gt;OpenRouter&lt;/a&gt;. The release notes in full:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Support for &lt;a href="https://llm.datasette.io/en/stable/tools.html"&gt;tool calling&lt;/a&gt;. Thanks, &lt;a href="https://github.com/jamessanford"&gt;James Sanford&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm-openrouter/pull/43"&gt;#43&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Support for reasoning options, for example &lt;code&gt;llm -m openrouter/openai/gpt-5 'prove dogs exist' -o reasoning_effort medium&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm-openrouter/issues/45"&gt;#45&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Tool calling is a really big deal, as it means you can now use the plugin to try out tools (and &lt;a href="https://simonwillison.net/2025/Sep/18/agents/"&gt;build agents, if you like&lt;/a&gt;) against any of the 179 tool-enabled models on that platform:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm install llm-openrouter
llm keys set openrouter
# Paste key here
llm models --tools | grep 'OpenRouter:' | wc -l
# Outputs 179
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Quite a few of the models hosted on OpenRouter can be accessed for free. Here's a tool-usage example using the &lt;a href="https://github.com/simonw/llm-tools-datasette"&gt;llm-tools-datasette plugin&lt;/a&gt; against the new &lt;a href="https://simonwillison.net/2025/Sep/20/grok-4-fast/"&gt;Grok 4 Fast model&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm install llm-tools-datasette
llm -m openrouter/x-ai/grok-4-fast:free -T 'Datasette("https://datasette.io/content")' 'Count available plugins'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Outputs:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;There are 154 available plugins.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/43c56203887dd0d07351443a2ba18f29"&gt;The output&lt;/a&gt; of &lt;code&gt;llm logs -cu&lt;/code&gt; shows the tool calls and SQL queries it executed to get that result.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-reasoning"&gt;llm-reasoning&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-tool-use"&gt;llm-tool-use&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openrouter"&gt;openrouter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;&lt;/p&gt;



</summary><category term="llm"/><category term="llm-reasoning"/><category term="llm-tool-use"/><category term="ai"/><category term="llms"/><category term="generative-ai"/><category term="projects"/><category term="openrouter"/><category term="datasette"/></entry><entry><title>Highlighted tools</title><link href="https://simonwillison.net/2025/Sep/4/highlighted-tools/#atom-tag" rel="alternate"/><published>2025-09-04T21:58:11+00:00</published><updated>2025-09-04T21:58:11+00:00</updated><id>https://simonwillison.net/2025/Sep/4/highlighted-tools/#atom-tag</id><summary type="html">
    &lt;p&gt;Any time I share my &lt;a href="https://tools.simonwillison.net/"&gt;collection of tools&lt;/a&gt; built using vibe coding and AI-assisted development (now at 124, here's &lt;a href="https://tools.simonwillison.net/colophon"&gt;the definitive list&lt;/a&gt;) someone will inevitably complain that they're mostly trivial.&lt;/p&gt;
&lt;p&gt;A lot of them are! Here's a list of some that I think are genuinely useful and worth highlighting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/ocr"&gt;OCR PDFs and images directly in your browser&lt;/a&gt;. This is the tool that started the collection, and I still use it on a regular basis. You can open any PDF in it (even PDFs that are just scanned images with no embedded text) and it will extract out the text so you can copy-and-paste it. It uses PDF.js and Tesseract.js to do that entirely in the browser. I wrote about &lt;a href="https://simonwillison.net/2024/Mar/30/ocr-pdfs-images/"&gt;how I originally built that here&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/annotated-presentations"&gt;Annotated Presentation Creator&lt;/a&gt; - this one is &lt;em&gt;so useful&lt;/em&gt;. I use it to turn talks that I've given into full annotated presentations, where each slide is accompanied by detailed notes. I have &lt;a href="https://simonwillison.net/tags/annotated-talks/"&gt;29 blog entries&lt;/a&gt; like that now and most of them were written with the help of this tool. Here's &lt;a href="https://simonwillison.net/2023/Aug/6/annotated-presentations/"&gt;how I built that&lt;/a&gt;, plus &lt;a href="https://tools.simonwillison.net/colophon#annotated-presentations.html"&gt;follow-up prompts I used to improve it&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/image-resize-quality"&gt;Image resize, crop, and quality comparison&lt;/a&gt; - I use this for every single image I post to my blog. It lets me drag (or paste) an image onto the page and then shows me a comparison of different sizes and quality settings, each of which I can download and then upload to my S3 bucket. I recently added a slightly janky but mobile-accessible cropping tool as well. &lt;a href="https://tools.simonwillison.net/colophon#image-resize-quality.html"&gt;Prompts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/social-media-cropper"&gt;Social Media Card Cropper&lt;/a&gt; - this is an even more useful image tool. Bluesky, Twitter etc all benefit from a 2x1 aspect ratio "card" image. I built this custom tool for creating those - you can paste in an image and crop and zoom it to the right dimensions. I use this all the time. &lt;a href="https://tools.simonwillison.net/colophon#social-media-cropper.html"&gt;Prompts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/svg-render"&gt;SVG to JPEG/PNG&lt;/a&gt; - every time I publish an &lt;a href="https://simonwillison.net/tags/pelican-riding-a-bicycle/"&gt;SVG of a pelican riding a bicycle&lt;/a&gt; I use this tool to turn that SVG into a JPEG or PNG. &lt;a href="https://tools.simonwillison.net/colophon#svg-render.html"&gt;Prompts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/encrypt"&gt;Encrypt / decrypt message&lt;/a&gt; - I often run workshops where I want to distribute API keys to the workshop participants. This tool lets me encrypt a message with a passphrase, then share the resulting URL to the encrypted message and tell people (with a note on a slide) how to decrypt it. &lt;a href="https://tools.simonwillison.net/colophon#encrypt.html"&gt;Prompt&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/jina-reader"&gt;Jina Reader&lt;/a&gt; - enter a URL, get back a Markdown version of the page. It's a thin wrapper over the Jina Reader API, but it's useful because it adds a "copy to clipboard" button which means it's one of the fastest way to turn a webpage into data on a clipboard on my mobile phone. I use this several times a week. &lt;a href="https://tools.simonwillison.net/colophon#jina-reader.html"&gt;Prompts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.llm-prices.com/"&gt;llm-prices.com&lt;/a&gt; - a pricing comparison and token pricing calculator for various hosted LLMs. This one started out as a tool but graduated to its own domain name. Here's the &lt;a href="https://tools.simonwillison.net/colophon#llm-prices.html"&gt;prompting development history&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/open-sauce-2025"&gt;Open Sauce 2025&lt;/a&gt; - an unofficial schedule for the Open Sauce conference, complete with option to export to ICS plus a search tool and now-and-next. I built this &lt;em&gt;entirely on my phone&lt;/em&gt; using OpenAI Codex, including scraping the official schedule - &lt;a href="https://simonwillison.net/2025/Jul/17/vibe-scraping/"&gt;full details here&lt;/a&gt;. &lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/hacker-news-histogram"&gt;Hacker News Multi-Term Histogram&lt;/a&gt; - compare search terms on Hacker News to see how their relative popularity changed over time. &lt;a href="https://tools.simonwillison.net/colophon#hacker-news-histogram.html"&gt;Prompts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/passkeys"&gt;Passkey experiment&lt;/a&gt; - a UI for trying out the Passkey / WebAuthn APIs that are built into browsers these days. &lt;a href="https://tools.simonwillison.net/colophon#passkeys.html"&gt;Prompts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/incomplete-json-printer"&gt;Incomplete JSON Pretty Printer&lt;/a&gt; - do you ever find yourself staring at a screen full of JSON that isn't completely valid because it got truncated? This tool will pretty-print it anyway. &lt;a href="https://tools.simonwillison.net/colophon#incomplete-json-printer.html"&gt;Prompts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tools.simonwillison.net/bluesky-firehose"&gt;Bluesky WebSocket Feed Monitor&lt;/a&gt; - I found out Bluesky has a Firehose API that can be accessed directly from the browser, so I vibe-coded up this tool to try it out. &lt;a href="https://tools.simonwillison.net/colophon#bluesky-firehose.html"&gt;Prompts&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In putting this list together I realized I wanted to be able to link to the prompts for each tool... but those were hidden inside a collapsed &lt;code&gt;&amp;lt;details&amp;gt;&amp;lt;summary&amp;gt;&lt;/code&gt; element for each one. So I fired up &lt;a href="https://openai.com/codex/"&gt;OpenAI Codex&lt;/a&gt; and prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Update the script that builds the colophon.html page such that the generated page has a tiny bit of extra JavaScript - when the page is loaded as e.g. https://tools.simonwillison.net/colophon#jina-reader.html it should notice the #jina-reader.html fragment identifier and ensure that the Development history details/summary for that particular tool is expanded when the page loads.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It &lt;a href="https://github.com/simonw/tools/pull/47"&gt;authored this PR for me&lt;/a&gt; which fixed the problem.&lt;/p&gt;

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



</summary><category term="vibe-coding"/><category term="ai-assisted-programming"/><category term="tools"/><category term="generative-ai"/><category term="projects"/><category term="ai"/><category term="llms"/><category term="coding-agents"/></entry><entry><title>simonw/codespaces-llm</title><link href="https://simonwillison.net/2025/Aug/13/codespaces-llm/#atom-tag" rel="alternate"/><published>2025-08-13T05:39:07+00:00</published><updated>2025-08-13T05:39:07+00:00</updated><id>https://simonwillison.net/2025/Aug/13/codespaces-llm/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/codespaces-llm"&gt;simonw/codespaces-llm&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;a href="https://github.com/features/codespaces"&gt;GitHub Codespaces&lt;/a&gt; provides full development environments in your browser, and is free to use with anyone with a GitHub account. Each environment has a full Linux container and a browser-based UI using VS Code.&lt;/p&gt;
&lt;p&gt;I found out today that GitHub Codespaces come with a &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; environment variable... and that token works as an API key for accessing LLMs in the &lt;a href="https://docs.github.com/en/github-models"&gt;GitHub Models&lt;/a&gt; collection, which includes &lt;a href="https://github.com/marketplace?type=models"&gt;dozens of models&lt;/a&gt; from OpenAI, Microsoft, Mistral, xAI, DeepSeek, Meta and more.&lt;/p&gt;
&lt;p&gt;Anthony Shaw's &lt;a href="https://github.com/tonybaloney/llm-github-models"&gt;llm-github-models&lt;/a&gt; plugin for my &lt;a href="https://llm.datasette.io/"&gt;LLM tool&lt;/a&gt; allows it to talk directly to GitHub Models. I filed &lt;a href="https://github.com/tonybaloney/llm-github-models/issues/49"&gt;a suggestion&lt;/a&gt; that it could pick up that &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; variable automatically and Anthony &lt;a href="https://github.com/tonybaloney/llm-github-models/releases/tag/0.18.0"&gt;shipped v0.18.0&lt;/a&gt; with that feature a few hours later.&lt;/p&gt;
&lt;p&gt;... which means you can now run the following in any Python-enabled Codespaces container and get a working &lt;code&gt;llm&lt;/code&gt; command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install llm
llm install llm-github-models
llm models default github/gpt-4.1
llm "Fun facts about pelicans"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting the default model to &lt;code&gt;github/gpt-4.1&lt;/code&gt; means you get free (albeit rate-limited) access to that OpenAI model.&lt;/p&gt;
&lt;p&gt;To save you from needing to even run that sequence of commands I've created a new GitHub repository, &lt;a href="https://github.com/simonw/codespaces-llm"&gt;simonw/codespaces-llm&lt;/a&gt;, which pre-installs and runs those commands for you.&lt;/p&gt;
&lt;p&gt;Anyone with a GitHub account can use this URL to launch a new Codespaces instance with a configured &lt;code&gt;llm&lt;/code&gt; terminal command ready to use:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://codespaces.new/simonw/codespaces-llm?quickstart=1"&gt;codespaces.new/simonw/codespaces-llm?quickstart=1&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a GitHub Codespaces VS Code interface showing a README.md file for codespaces-llm repository. The file describes a GitHub Codespaces environment with LLM, Python 3.13, uv and the GitHub Copilot VS Code extension. It has a &amp;quot;Launch Codespace&amp;quot; button. Below shows a terminal tab with the command &amp;quot;llm 'Fun facts about pelicans'&amp;quot; which has generated output listing 5 pelican facts: 1. **Huge Beaks:** about their enormous beaks and throat pouches for scooping fish and water, some over a foot long; 2. **Fishing Technique:** about working together to herd fish into shallow water; 3. **Great Fliers:** about being strong fliers that migrate great distances and soar on thermals; 4. **Buoyant Bodies:** about having air sacs beneath skin and bones making them extra buoyant; 5. **Dive Bombing:** about Brown Pelicans diving dramatically from air into water to catch fish." src="https://static.simonwillison.net/static/2025/codespaces-llm.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;While putting this together I wrote up what I've learned about devcontainers so far as a TIL: &lt;a href="https://til.simonwillison.net/github/codespaces-devcontainers"&gt;Configuring GitHub Codespaces using devcontainers&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthony-shaw"&gt;anthony-shaw&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-codespaces"&gt;github-codespaces&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;&lt;/p&gt;



</summary><category term="llm"/><category term="til"/><category term="ai"/><category term="llms"/><category term="anthony-shaw"/><category term="generative-ai"/><category term="github-codespaces"/><category term="projects"/><category term="github"/><category term="openai"/><category term="python"/></entry><entry><title>LLM 0.27, the annotated release notes: GPT-5 and improved tool calling</title><link href="https://simonwillison.net/2025/Aug/11/llm-027/#atom-tag" rel="alternate"/><published>2025-08-11T23:57:50+00:00</published><updated>2025-08-11T23:57:50+00:00</updated><id>https://simonwillison.net/2025/Aug/11/llm-027/#atom-tag</id><summary type="html">
    &lt;p&gt;I shipped &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-27"&gt;LLM 0.27&lt;/a&gt; today (followed by a &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-27-1"&gt;0.27.1 with minor bug fixes&lt;/a&gt;), adding support for the new GPT-5 family of models from OpenAI plus a flurry of improvements to the tool calling features &lt;a href="https://simonwillison.net/2025/May/27/llm-tools/"&gt;introduced in LLM 0.26&lt;/a&gt;. Here are the &lt;a href="https://simonwillison.net/tags/annotated-release-notes/"&gt;annotated release notes&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="gpt-5"&gt;GPT-5&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New models: &lt;code&gt;gpt-5&lt;/code&gt;, &lt;code&gt;gpt-5-mini&lt;/code&gt; and &lt;code&gt;gpt-5-nano&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1229"&gt;#1229&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I would have liked to get these out sooner, but LLM had accumulated quite a lot of other changes since the last release and I wanted to use GPT-5 as an excuse to wrap all of those up and get them out there.&lt;/p&gt;
&lt;p&gt;These models work much the same as other OpenAI models, but they have a new &lt;code&gt;reasoning_effort&lt;/code&gt; option of &lt;code&gt;minimal&lt;/code&gt;. You can try that out like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -m gpt-5 'A letter advocating for cozy boxes for pelicans in Half Moon Bay harbor' -o reasoning_effort minimal
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting "minimal" almost completely eliminates the "thinking" time for the model, causing it to behave more like GPT-4o.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/49838dbca944d3f22dfe65ef11c5637d"&gt;the letter it wrote me&lt;/a&gt; at a cost of 20 input, 706 output = &lt;a href="https://www.llm-prices.com/#it=20&amp;amp;ot=706&amp;amp;ic=1.25&amp;amp;oc=10"&gt;$0.007085 which is 0.7085 cents&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can set the default model to GPT-5-mini (since it's a bit cheaper) like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm models default gpt-5-mini
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id="tools-in-templates"&gt;Tools in templates&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;LLM &lt;a href="https://llm.datasette.io/en/stable/templates.html#prompt-templates"&gt;templates&lt;/a&gt; can now include a list of tools. These can be named tools from plugins or arbitrary Python function blocks, see &lt;a href="https://llm.datasette.io/en/stable/templates.html#prompt-templates-tools"&gt;Tools in templates&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1009"&gt;#1009&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I think this is the most important feature in the new release.&lt;/p&gt;
&lt;p&gt;I added LLM's &lt;a href="https://simonwillison.net/2025/May/27/llm-tools/"&gt;tool calling features&lt;/a&gt; in LLM 0.26. You can call them from the Python API but you can also call them from the command-line like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -T llm_version -T llm_time 'Tell the time, then show the version'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/65d830f8cb38cdeb78093d6ac890ce2c#response-1"&gt;the output&lt;/a&gt; of &lt;code&gt;llm logs -c&lt;/code&gt; after running that command.&lt;/p&gt;
&lt;p&gt;This example shows that you have to explicitly list all of the tools you would like to expose to the model, using the &lt;code&gt;-T/--tool&lt;/code&gt; option one or more times.&lt;/p&gt;
&lt;p&gt;In LLM 0.27 you can now save these tool collections to &lt;a href="https://llm.datasette.io/en/stable/templates.html"&gt;a template&lt;/a&gt;. Let's try that now:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -T llm_version -T llm_time -m gpt-5 --save mytools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;code&gt;mytools&lt;/code&gt; is a template that bundles those two tools and sets the default model to GPT-5. We can run it like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -t mytools 'Time then version'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let's do something more fun. My blog has a &lt;a href="https://datasette.simonwillison.net/"&gt;Datasette mirror&lt;/a&gt; which I can run queries against. I'm going to use the &lt;a href="https://github.com/simonw/llm-tools-datasette"&gt;llm-tools-datasette&lt;/a&gt; plugin to turn that into a tool-driven template. This plugin uses a "toolbox", which looks a bit like a class. Those are &lt;a href="https://llm.datasette.io/en/stable/python-api.html#toolbox-classes"&gt;described here&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm install llm-tools-datasette

# Now create that template
llm --tool 'Datasette("https://datasette.simonwillison.net/simonwillisonblog")' \
  -m gpt-5 -s 'Use Datasette tools to answer questions' --save blog
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now I can ask questions of my database like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -t blog 'top ten tags by number of entries'&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--td&lt;/code&gt; option there stands for &lt;code&gt;--tools-debug&lt;/code&gt; - it means we can see all tool calls as they are run.&lt;/p&gt;
&lt;p&gt;Here's the output of the above:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Top 10 tags by number of entries (excluding drafts):
- quora — 1003
- projects — 265
- datasette — 238
- python — 213
- ai — 200
- llms — 200
- generative-ai — 197
- weeknotes — 193
- web-development — 166
- startups — 157
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/7b2d0d327afc32ad6c90179fa76290ad"&gt;Full transcript with tool traces here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I'm really excited about the ability to store configured tools&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Tools &lt;a href="https://llm.datasette.io/en/stable/python-api.html#python-api-tools-attachments"&gt;can now return attachments&lt;/a&gt;, for models that support features such as image input. &lt;a href="https://github.com/simonw/llm/issues/1014"&gt;#1014&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I want to build a tool that can render SVG to an image, then return that image so the model can see what it has drawn. For reasons.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New methods on the &lt;code&gt;Toolbox&lt;/code&gt; class: &lt;code&gt;.add_tool()&lt;/code&gt;, &lt;code&gt;.prepare()&lt;/code&gt; and &lt;code&gt;.prepare_async()&lt;/code&gt;, described in &lt;a href="https://llm.datasette.io/en/stable/python-api.html#python-api-tools-dynamic"&gt;Dynamic toolboxes&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1111"&gt;#1111&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I added these because there's a lot of interest in an MCP plugin for Datasette. Part of the challenge with MCP is that the user provides the URL to a server but we then need to introspect that server and dynamically add the tools we have discovered there. The new &lt;code&gt;.add_tool()&lt;/code&gt; method can do that, and the &lt;code&gt;.prepare()&lt;/code&gt; and &lt;code&gt;.prepare_async()&lt;/code&gt; methods give us a reliable way to run some discovery code outside of the class constructor, allowing it to make asynchronous calls if necessary.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;model.conversation(before_call=x, after_call=y)&lt;/code&gt; attributes for registering callback functions to run before and after tool calls. See &lt;a href="https://llm.datasette.io/en/stable/python-api.html#python-api-tools-debug-hooks"&gt;tool debugging hooks&lt;/a&gt; for details. &lt;a href="https://github.com/simonw/llm/issues/1088"&gt;#1088&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Raising &lt;code&gt;llm.CancelToolCall&lt;/code&gt; now only cancels the current tool call, passing an error back to the model and allowing it to continue. &lt;a href="https://github.com/simonw/llm/issues/1148"&gt;#1148&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;These hooks are useful for implementing more complex tool calling at the Python API layer. In addition to debugging and logging they allow Python code to intercept tool calls and cancel or delay them based on what they are trying to do.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Some model providers can serve different models from the same configured URL - &lt;a href="https://github.com/simonw/llm-llama-server"&gt;llm-llama-server&lt;/a&gt; for example. Plugins for these providers can now record the resolved model ID of the model that was used to the LLM logs using the &lt;code&gt;response.set_resolved_model(model_id)&lt;/code&gt; method. &lt;a href="https://github.com/simonw/llm/issues/1117"&gt;#1117&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This solves a frustration I've had for a while where some of my plugins log the same model ID for requests that were processed by a bunch of different models under the hood - making my logs less valuable. The new mechanism now allows plugins to record a more accurate model ID for a prompt, should it differ from the model ID that was requsted.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;-l/--latest&lt;/code&gt; option for &lt;code&gt;llm logs -q searchterm&lt;/code&gt; for searching logs ordered by date (most recent first) instead of the default relevance search. &lt;a href="https://github.com/simonw/llm/issues/1177"&gt;#1177&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;My personal &lt;a href="https://llm.datasette.io/en/stable/logging.html"&gt;log database&lt;/a&gt; has grown to over 8,000 entries now, and running full-text search queries against it often returned results from last year that were no longer relevant to me. Being able to find the &lt;em&gt;latest&lt;/em&gt; prompt matching "pelican svg" is much more useful.&lt;/p&gt;
&lt;p&gt;Everything else was bug fixes and documentation improvements:&lt;/p&gt;
&lt;blockquote&gt;
&lt;h3 id="bug-fixes-and-documentation"&gt;Bug fixes and documentation&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;register_embedding_models&lt;/code&gt; hook is &lt;a href="https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-embedding-models-register"&gt;now documented&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1049"&gt;#1049&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Show visible stack trace for &lt;code&gt;llm templates show invalid-template-name&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1053"&gt;#1053&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Handle invalid tool names more gracefully in &lt;code&gt;llm chat&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1104"&gt;#1104&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Add a &lt;a href="https://llm.datasette.io/en/stable/plugins/directory.html#plugin-directory-tools"&gt;Tool plugins&lt;/a&gt; section to the plugin directory. &lt;a href="https://github.com/simonw/llm/issues/1110"&gt;#1110&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Error on &lt;code&gt;register(Klass)&lt;/code&gt; if the passed class is not a subclass of &lt;code&gt;Toolbox&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/1114"&gt;#1114&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;-h&lt;/code&gt; for &lt;code&gt;--help&lt;/code&gt; for all &lt;code&gt;llm&lt;/code&gt; CLI commands. &lt;a href="https://github.com/simonw/llm/issues/1134"&gt;#1134&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Add missing &lt;code&gt;dataclasses&lt;/code&gt; to advanced model plugins docs. &lt;a href="https://github.com/simonw/llm/issues/1137"&gt;#1137&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fixed a bug where &lt;code&gt;llm logs -T llm_version "version" --async&lt;/code&gt; incorrectly recorded just one single log entry when it should have recorded two. &lt;a href="https://github.com/simonw/llm/issues/1150"&gt;#1150&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;All extra OpenAI model keys in &lt;code&gt;extra-openai-models.yaml&lt;/code&gt; are &lt;a href="https://llm.datasette.io/en/stable/other-models.html#openai-compatible-models"&gt;now documented&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/1228"&gt;#1228&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-tool-use"&gt;llm-tool-use&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt-5"&gt;gpt-5&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="llm"/><category term="annotated-release-notes"/><category term="llm-tool-use"/><category term="ai"/><category term="llms"/><category term="gpt-5"/><category term="generative-ai"/><category term="projects"/><category term="datasette"/><category term="python"/></entry></feed>