<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: annotated-release-notes</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/annotated-release-notes.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-06-21T23:35:47+00:00</updated><author><name>Simon Willison</name></author><entry><title>sqlite-utils 4.0rc1 adds migrations and nested transactions</title><link href="https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/#atom-tag" rel="alternate"/><published>2026-06-21T23:35:47+00:00</published><updated>2026-06-21T23:35:47+00:00</updated><id>https://simonwillison.net/2026/Jun/21/sqlite-utils-40rc1/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;a href="https://sqlite-utils.datasette.io/en/latest/"&gt;sqlite-utils&lt;/a&gt; is my combined Python library and CLI tool for working with SQLite databases. It provides an extensive set of higher-level operations on top of Python's default &lt;a href="https://docs.python.org/3/library/sqlite3.html"&gt;sqlite3 package&lt;/a&gt;, including support for &lt;a href="https://sqlite-utils.datasette.io/en/latest/cli.html#transforming-tables"&gt;complex table transformations&lt;/a&gt;, automatic table creation &lt;a href="https://sqlite-utils.datasette.io/en/latest/cli.html#inserting-json-data"&gt;from JSON data&lt;/a&gt; and a whole lot more.&lt;/p&gt;
&lt;p&gt;I released &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#rc1-2026-06-21"&gt;sqlite-utils 4.0rc1&lt;/a&gt;, the first release candidate for sqlite-utils v4. The major version bump indicates some (minor) backwards incompatible changes, so I'm interested in having people try this out before I commit to a stable release.&lt;/p&gt;
&lt;h4 id="new-feature-migrations"&gt;New feature: migrations&lt;/h4&gt;
&lt;p&gt;There are two significant new features in this RC compared to the previous 4.0 alphas.&lt;/p&gt;
&lt;p&gt;The first is support for &lt;strong&gt;database migrations&lt;/strong&gt;. This isn't a completely new implementation - it's a slightly modified port of the &lt;a href="https://github.com/simonw/sqlite-migrate"&gt;sqlite-migrate&lt;/a&gt; package I released a few years ago. I think that package has proved itself over time, so I'm now ready to bundle it with &lt;code&gt;sqlite-utils&lt;/code&gt; directly.&lt;/p&gt;
&lt;p&gt;Here's what a set of migrations in a &lt;code&gt;migrations.py&lt;/code&gt; file looks like:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;Database&lt;/span&gt;, &lt;span class="pl-v"&gt;Migrations&lt;/span&gt;

&lt;span class="pl-s1"&gt;migrations&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Migrations&lt;/span&gt;(&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;)

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;create_table&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;create&lt;/span&gt;(
        {&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s1"&gt;int&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;, &lt;span class="pl-s"&gt;"species"&lt;/span&gt;: &lt;span class="pl-s1"&gt;str&lt;/span&gt;},
        &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;,
    )

&lt;span class="pl-en"&gt;@&lt;span class="pl-en"&gt;migrations&lt;/span&gt;()&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;add_weight&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s"&gt;"creatures"&lt;/span&gt;].&lt;span class="pl-c1"&gt;add_column&lt;/span&gt;(&lt;span class="pl-s"&gt;"weight"&lt;/span&gt;, &lt;span class="pl-s1"&gt;float&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;This defines a set of two migrations, one creating the &lt;code&gt;creatures&lt;/code&gt; table and another adding a column to it.&lt;/p&gt;
&lt;p&gt;You can then run those migrations either using Python:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;Database&lt;/span&gt;(&lt;span class="pl-s"&gt;"creatures.db"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;migrations&lt;/span&gt;.&lt;span class="pl-c1"&gt;apply&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Or with the command-line &lt;code&gt;migrate&lt;/code&gt; command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;sqlite-utils migrate creatures.db migrations.py&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The system is deliberately small: it doesn't provide reverse migrations, so any mistakes you make should be fixed by deploying a fresh migration to undo them.&lt;/p&gt;
&lt;p&gt;Its predecessor has been used by &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; and various other projects for several years, so I'm confident that the design is stable and works well.&lt;/p&gt;
&lt;p&gt;The new migrations feature &lt;a href="https://sqlite-utils.datasette.io/en/latest/migrations.html"&gt;is documented here&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="new-feature-db-atomic-transactions"&gt;New feature: db.atomic() transactions&lt;/h4&gt;
&lt;p&gt;This feature is a lot less exercised than migrations, so it deserves more attention from testers.&lt;/p&gt;
&lt;p&gt;Previously, &lt;code&gt;sqlite-utils&lt;/code&gt; mostly left transaction management up to its users, via a &lt;code&gt;with db.conn:&lt;/code&gt; construct that reused the &lt;code&gt;sqlite3&lt;/code&gt; mechanism directly.&lt;/p&gt;
&lt;p&gt;SQLite supports nested transactions in the form of savepoints, so I wanted an abstraction that could make those as easy to use as possible.&lt;/p&gt;
&lt;p&gt;I borrowed the terminology "atomic" from Django and Peewee. Here's what the new API looks like:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;atomic&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Cleo"&lt;/span&gt;}, &lt;span class="pl-s1"&gt;pk&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"id"&lt;/span&gt;)
    &lt;span class="pl-k"&gt;try&lt;/span&gt;:
        &lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;atomic&lt;/span&gt;():
            &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Pancakes"&lt;/span&gt;})
            &lt;span class="pl-k"&gt;raise&lt;/span&gt; &lt;span class="pl-en"&gt;ValueError&lt;/span&gt;(&lt;span class="pl-s"&gt;"skip this one"&lt;/span&gt;)
    &lt;span class="pl-k"&gt;except&lt;/span&gt; &lt;span class="pl-v"&gt;ValueError&lt;/span&gt;:
        &lt;span class="pl-k"&gt;pass&lt;/span&gt;
    &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table&lt;/span&gt;(&lt;span class="pl-s"&gt;"dogs"&lt;/span&gt;).&lt;span class="pl-c1"&gt;insert&lt;/span&gt;({&lt;span class="pl-s"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;3&lt;/span&gt;, &lt;span class="pl-s"&gt;"name"&lt;/span&gt;: &lt;span class="pl-s"&gt;"Marnie"&lt;/span&gt;})&lt;/pre&gt;
&lt;p&gt;More details &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#transactions-with-db-atomic"&gt;in the documentation&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="backwards-incompatible-changes"&gt;Backwards incompatible changes&lt;/h4&gt;
&lt;p&gt;The backwards incompatible changes in v4 were described in the alpha release notes. For &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#a0-2025-05-08"&gt;4.0a0&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Upsert operations now use SQLite's &lt;code&gt;INSERT ... ON CONFLICT SET&lt;/code&gt; syntax on all SQLite versions later than 3.23.1. This is a very slight breaking change for apps that depend on the previous &lt;code&gt;INSERT OR IGNORE&lt;/code&gt; followed by &lt;code&gt;UPDATE&lt;/code&gt; behavior. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/652"&gt;#652&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Python library users can opt-in to the previous implementation by passing &lt;code&gt;use_old_upsert=True&lt;/code&gt; to the &lt;code&gt;Database()&lt;/code&gt; constructor, see &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-old-upsert"&gt;Alternative upserts using INSERT OR IGNORE&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Dropped support for Python 3.8, added support for Python 3.13. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/646"&gt;#646&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sqlite-utils tui&lt;/code&gt; is now provided by the &lt;a href="https://github.com/simonw/sqlite-utils-tui"&gt;sqlite-utils-tui&lt;/a&gt; plugin. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/648"&gt;#648&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Test suite now also runs against SQLite 3.23.1, the last version (from 2018-04-10) before the new &lt;code&gt;INSERT ... ON CONFLICT SET&lt;/code&gt; syntax was added. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/654"&gt;#654&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;And for &lt;a href="https://sqlite-utils.datasette.io/en/latest/changelog.html#a1-2025-11-23"&gt;4.0a1&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The &lt;code&gt;db.table(table_name)&lt;/code&gt; method now only works with tables. To access a SQL view use &lt;code&gt;db.view(view_name)&lt;/code&gt; instead. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/657"&gt;#657&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;table.insert_all()&lt;/code&gt; and &lt;code&gt;table.upsert_all()&lt;/code&gt; methods can now accept an iterator of lists or tuples as an alternative to dictionaries. The first item should be a list/tuple of column names. See &lt;a href="https://sqlite-utils.datasette.io/en/latest/python-api.html#python-api-insert-lists"&gt;Inserting data from a list or tuple iterator&lt;/a&gt; for details. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/672"&gt;#672&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The default floating point column type has been changed from &lt;code&gt;FLOAT&lt;/code&gt; to &lt;code&gt;REAL&lt;/code&gt;, which is the correct SQLite type for floating point values. This affects auto-detected columns when inserting data. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/645"&gt;#645&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Now uses &lt;code&gt;pyproject.toml&lt;/code&gt; in place of &lt;code&gt;setup.py&lt;/code&gt; for packaging. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/675"&gt;#675&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Tables in the Python API now do a much better job of remembering the primary key and other schema details from when they were first created. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/655"&gt;#655&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: The &lt;code&gt;table.convert()&lt;/code&gt; and &lt;code&gt;sqlite-utils convert&lt;/code&gt; mechanisms no longer skip values that evaluate to &lt;code&gt;False&lt;/code&gt;. Previously the &lt;code&gt;--skip-false&lt;/code&gt; option was needed, this has been removed. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/542"&gt;#542&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change&lt;/strong&gt;: Tables created by this library now wrap table and column names in &lt;code&gt;"double-quotes"&lt;/code&gt; in the schema. Previously they would use &lt;code&gt;[square-braces]&lt;/code&gt;. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/677"&gt;#677&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;--functions&lt;/code&gt; CLI argument now accepts a path to a Python file in addition to accepting a string full of Python code. It can also now be specified multiple times. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/659"&gt;#659&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breaking change:&lt;/strong&gt; Type detection is now the default behavior for the &lt;code&gt;insert&lt;/code&gt; and &lt;code&gt;upsert&lt;/code&gt; CLI commands when importing CSV or TSV data. Previously all columns were treated as &lt;code&gt;TEXT&lt;/code&gt; unless the &lt;code&gt;--detect-types&lt;/code&gt; flag was passed. Use the new &lt;code&gt;--no-detect-types&lt;/code&gt; flag to restore the old behavior. The &lt;code&gt;SQLITE_UTILS_DETECT_TYPES&lt;/code&gt; environment variable has been removed. (&lt;a href="https://github.com/simonw/sqlite-utils/issues/679"&gt;#679&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h4 id="try-it-out"&gt;Try it out&lt;/h4&gt;
&lt;p&gt;You can install the new RC like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;pip install sqlite-utils==4.0rc1&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Or try the CLI version directly with &lt;code&gt;uvx&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx --with sqlite-utils==4.0rc1 sqlite-utils --help&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Come chat with us about it in the &lt;a href="https://discord.gg/Ass7bCAMDw"&gt;sqlite-utils Discord channel&lt;/a&gt;, or file any bugs in &lt;a href="https://github.com/simonw/sqlite-utils/issues"&gt;GitHub Issues&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/migrations"&gt;migrations&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="migrations"/><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/></entry><entry><title>datasette 1.0a34</title><link href="https://simonwillison.net/2026/Jun/16/datasette/#atom-tag" rel="alternate"/><published>2026-06-16T21:31:24+00:00</published><updated>2026-06-16T21:31:24+00:00</updated><id>https://simonwillison.net/2026/Jun/16/datasette/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette/releases/tag/1.0a34"&gt;datasette 1.0a34&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Quoting the release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The big feature in this alpha is tools to insert, edit and delete rows within the Datasette interface. These features are available on table pages, and edit and delete are also available as action items on the row page.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;click-to-play&gt;&lt;a href="https://static.simonwillison.net/static/2026/datasette-edit.gif"&gt;&lt;img src="https://static.simonwillison.net/static/2026/datasette-edit-first-frame.gif" /&gt;&lt;/a&gt;&lt;/click-to-play&gt;&lt;/p&gt;

&lt;p&gt;The inspiration for this feature - which is &lt;em&gt;long&lt;/em&gt; overdue - was &lt;a href="https://agent.datasette.io/"&gt;Datasette Agent&lt;/a&gt;. I added &lt;a href="https://simonwillison.net/2026/Jun/15/datasette-agent/"&gt;SQL write support&lt;/a&gt; to that the other day which highlighted how absurd it was that you could insert and edit ties via the chat interface but not in the regular Datasette UI!&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="datasette"/><category term="annotated-release-notes"/></entry><entry><title>datasette-agent 0.3a0</title><link href="https://simonwillison.net/2026/Jun/15/datasette-agent/#atom-tag" rel="alternate"/><published>2026-06-15T17:19:27+00:00</published><updated>2026-06-15T17:19:27+00:00</updated><id>https://simonwillison.net/2026/Jun/15/datasette-agent/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/datasette/datasette-agent/releases/tag/0.3a0"&gt;datasette-agent 0.3a0&lt;/a&gt;&lt;/p&gt;
        &lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New tool, &lt;code&gt;execute_write_sql&lt;/code&gt;, which requests user approval and then writes to a database - taking user permissions into account. &lt;a href="https://github.com/datasette/datasette-agent/issues/27"&gt;#27&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I added a mechanism for asking user approval in &lt;a href="https://simonwillison.net/2026/Jun/10/datasette-agent/"&gt;datasette agent 0.2a0&lt;/a&gt;. The new &lt;code&gt;execute_write_sql&lt;/code&gt; tool can now prompt the user for all kinds of useful operations. Here's an example where I add some pelican sightings to my &lt;code&gt;pelican_sightings&lt;/code&gt; table:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of a chat interface showing a write SQL confirmation dialog. User message (blue bubble): &amp;quot;I saw 4 pelicans flying over the harbor&amp;quot;. Collapsed tool section: &amp;quot;► Tool: execute_write_sql&amp;quot;. A yellow-bordered confirmation card reads: &amp;quot;Confirm write SQL batch / Database: pelicans / Statements execute in order. If one statement fails, later statements will not be executed. / Statement 1 / INSERT INTO pelican_sightings (number_of_pelicans, notes) VALUES (:number_of_pelicans, :notes); / number_of_pelicans 4 / notes Flying over the harbor&amp;quot;. A table with columns &amp;quot;Operation, Database, Table, Required permissions&amp;quot; shows row: &amp;quot;insert, pelicans, pelican_sightings&amp;quot; with permission buttons &amp;quot;insert-row&amp;quot;, &amp;quot;update-row&amp;quot;, &amp;quot;delete-row&amp;quot;. Below: &amp;quot;Execute 1 write SQL statement against database 'pelicans'? / Asked by tool: execute_write_sql&amp;quot; with &amp;quot;Yes&amp;quot; (blue) and &amp;quot;No&amp;quot; (gray) buttons." src="https://static.simonwillison.net/static/2026/i-saw-pelicans-agent.jpg" /&gt;&lt;/p&gt;
&lt;p&gt;The new version also enhances the &lt;code&gt;datasette agent chat&lt;/code&gt; terminal mode to support approvals, and adds several new options including &lt;code&gt;--unsafe&lt;/code&gt; mode for auto-approving them:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;datasette agent chat&lt;/code&gt; can execute tools that require user approval. &lt;a href="https://github.com/datasette/datasette-agent/issues/30"&gt;#30&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Three new options for &lt;code&gt;datasette agent chat&lt;/code&gt; - &lt;code&gt;--root&lt;/code&gt; to run as root, &lt;code&gt;--yes&lt;/code&gt; to approve all ask user questions, and &lt;code&gt;--unsafe&lt;/code&gt; for both.&lt;/li&gt;
&lt;li&gt;Tools can now provide plain text alternatives to HTML, for display in the &lt;code&gt;datasette agent chat&lt;/code&gt; CLI. &lt;a href="https://github.com/datasette/datasette-agent/issues/31"&gt;#31&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;code&gt;datasette agent chat content.db -m gpt-5.5 --unsafe&lt;/code&gt; command can now be used to chat directly with a specific database &lt;em&gt;and&lt;/em&gt; directly modify it through prompts like "create a notes table", "add a note about X" etc.&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/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-tool-use"&gt;llm-tool-use&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-agent"&gt;datasette-agent&lt;/a&gt;&lt;/p&gt;
    

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

</summary><category term="projects"/><category term="datasette"/><category term="annotated-release-notes"/><category term="ai-assisted-programming"/></entry><entry><title>datasette 1.0a32</title><link href="https://simonwillison.net/2026/May/31/datasette/#atom-tag" rel="alternate"/><published>2026-05-31T23:23:38+00:00</published><updated>2026-05-31T23:23:38+00:00</updated><id>https://simonwillison.net/2026/May/31/datasette/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette/releases/tag/1.0a32"&gt;datasette 1.0a32&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;A minor bugfix release. Fixes a bug with &lt;code&gt;INSERT ... RETURNING&lt;/code&gt; queries via the &lt;a href="https://datasette.io/blog/2026/sql-write-queries/"&gt;new /db/-/execute-write endpoint&lt;/a&gt; and a bunch of &lt;a href="https://docs.datasette.io/en/latest/settings.html#setting-base-url"&gt;base_url&lt;/a&gt; issues which showed up when I was &lt;a href="https://simonwillison.net/2026/May/30/pyodide-asgi-browser/"&gt;experimenting with Service Workers&lt;/a&gt; yesterday.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="datasette"/><category term="annotated-release-notes"/></entry><entry><title>datasette 1.0a31</title><link href="https://simonwillison.net/2026/May/29/datasette/#atom-tag" rel="alternate"/><published>2026-05-29T03:32:02+00:00</published><updated>2026-05-29T03:32:02+00:00</updated><id>https://simonwillison.net/2026/May/29/datasette/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette/releases/tag/1.0a31"&gt;datasette 1.0a31&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Another significant alpha release, with two new headline features.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Datasette now offers users with the necessary permissions the ability to both &lt;strong&gt;execute write queries&lt;/strong&gt; against their database and to &lt;strong&gt;save stored queries&lt;/strong&gt; (renamed from "canned queries") both privately and for use by other members of their Datasette instance.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There's more detail in &lt;a href="https://datasette.io/blog/2026/sql-write-queries/"&gt;SQL write queries and stored queries in Datasette 1.0a31&lt;/a&gt; on the Datasette blog, which now has &lt;a href="https://datasette.io/blog/"&gt;three posts introducing new features&lt;/a&gt; since the blog launched two weeks ago.&lt;/p&gt;
&lt;p&gt;Here's an animated demo from &lt;a href="https://datasette.io/blog/2026/sql-write-queries/"&gt;the blog post&lt;/a&gt; showing how the new execute query interface lets people get started with templated insert/update/delete queries from tables they have permission to edit:&lt;/p&gt;
&lt;p&gt;&lt;img alt="The user starts on the data database page, selects actions and &amp;quot;Execute write SQL&amp;quot;, then selects the insert document template on the next page and executes it with a title of &amp;quot;My document!&amp;quot;. Also demonstrates that a create table statement cannot be executed because the user does not have create-table permission." src="https://datasette.io/static/blog/2026/sql-write-ui.gif" /&gt;&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/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/datasette"&gt;datasette&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="sql"/><category term="sqlite"/><category term="datasette"/><category term="annotated-release-notes"/></entry><entry><title>datasette 1.0a30</title><link href="https://simonwillison.net/2026/May/24/datasette/#atom-tag" rel="alternate"/><published>2026-05-24T23:52:37+00:00</published><updated>2026-05-24T23:52:37+00:00</updated><id>https://simonwillison.net/2026/May/24/datasette/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette/releases/tag/1.0a30"&gt;datasette 1.0a30&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;The big new feature in this alpha is a new customizable "Jump to..." menu, described in detail in &lt;a href="https://datasette.io/blog/2026/jump-menu/"&gt;The extensible "Jump to" menu in Datasette 1.0a30&lt;/a&gt; on the Datasette blog. You can try it out by hitting &lt;code&gt;/&lt;/code&gt; on &lt;a href="https://latest.datasette.io/"&gt;latest.datasette.io&lt;/a&gt; - it looks like this:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Animated demo - the Jump to menu appears, and as the user types it filters to specific databases and tables and debug options" src="https://static.simonwillison.net/static/2026/menu.gif" /&gt;&lt;/p&gt;
&lt;p&gt;The new &lt;a href="https://docs.datasette.io/en/latest/plugin_hooks.html#jump-items-sql-datasette-actor-request"&gt;jump_items_sql()&lt;/a&gt; plugin hook allows plugins to add their own items to the set that's searched by the plugin.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="datasette"/><category term="annotated-release-notes"/></entry><entry><title>llm 0.32a2</title><link href="https://simonwillison.net/2026/May/12/llm/#atom-tag" rel="alternate"/><published>2026-05-12T17:45:07+00:00</published><updated>2026-05-12T17:45:07+00:00</updated><id>https://simonwillison.net/2026/May/12/llm/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/llm/releases/tag/0.32a2"&gt;llm 0.32a2&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;A bunch of useful stuff in this &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; alpha, but the most important detail is this one:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Most reasoning-capable OpenAI models now use the &lt;a href="https://developers.openai.com/api/reference/responses/overview"&gt;&lt;code&gt;/v1/responses&lt;/code&gt;&lt;/a&gt; endpoint instead of &lt;code&gt;/v1/chat/completions&lt;/code&gt;. This enables interleaved reasoning across tool calls for GPT-5 class models. &lt;a href="https://github.com/simonw/llm/pull/1435"&gt;#1435&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This means you can now see the summarized reasoning tokens when you run prompts against an OpenAI model, displayed in a different color to standard error. Use the &lt;code&gt;-R&lt;/code&gt; or &lt;code&gt;--hide-reasoning&lt;/code&gt; flags if you don't want to see that.&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/ai"&gt;ai&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/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="ai"/><category term="annotated-release-notes"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="llm"/></entry><entry><title>LLM 0.32a0  is a major backwards-compatible refactor</title><link href="https://simonwillison.net/2026/Apr/29/llm/#atom-tag" rel="alternate"/><published>2026-04-29T19:01:47+00:00</published><updated>2026-04-29T19:01:47+00:00</updated><id>https://simonwillison.net/2026/Apr/29/llm/#atom-tag</id><summary type="html">
    &lt;p&gt;I just released &lt;a href="https://llm.datasette.io/en/latest/changelog.html#a0-2026-04-28"&gt;LLM 0.32a0&lt;/a&gt;, an alpha release of my &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; Python library and CLI tool for accessing LLMs, with some consequential changes that I've been working towards for quite a while.&lt;/p&gt;
&lt;p&gt;Previous versions of LLM modeled the world in terms of prompts and responses. Send the model a text prompt, get back a text response.&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Capital of France?"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;This made sense when I started working on the library back in April 2023. A lot has changed since then!&lt;/p&gt;
&lt;p&gt;LLM provides an abstraction over thousands of different models via its &lt;a href="https://llm.datasette.io/en/stable/plugins/index.html"&gt;plugin system&lt;/a&gt;. The original abstraction - of text input that returns text output - was no longer able to represent everything I needed it to.&lt;/p&gt;
&lt;p&gt;Over time LLM itself has grown &lt;a href="https://simonwillison.net/2024/Oct/29/llm-multi-modal/"&gt;attachments&lt;/a&gt; to handle image, audio, and video input, then &lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/"&gt;schemas&lt;/a&gt; for outputting structured JSON, then &lt;a href="https://simonwillison.net/2025/May/27/llm-tools/"&gt;tools&lt;/a&gt; for executing tool calls. Meanwhile LLMs kept evolving, adding reasoning support and the ability to return images and all kinds of other interesting capabilities.&lt;/p&gt;
&lt;p&gt;LLM needs to evolve to better handle the diversity of input and output types that can be processed by today's frontier models.&lt;/p&gt;
&lt;p&gt;The 0.32a0 alpha has two key changes: model inputs can be represented as a sequence of messages, and model responses can be composed of a stream of differently typed parts.&lt;/p&gt;
&lt;h4 id="prompts-as-a-sequence-of-messages"&gt;Prompts as a sequence of messages&lt;/h4&gt;
&lt;p&gt;LLMs accept input as text, but ever since ChatGPT demonstrated the value of a two-way conversational interface, the most common way to prompt them has been to treat that input as a sequence of conversational turns.&lt;/p&gt;
&lt;p&gt;The first turn might look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user: Capital of France?
assistant: 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(The model then gets to fill out the reply from the assistant.)&lt;/p&gt;
&lt;p&gt;But each subsequent turn needs to replay the entire conversation up to that point, as a sort of screenplay:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user: Capital of France?
assistant: Paris
user: Germany?
assistant:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of the JSON APIs from the major vendors follow this pattern. Here's what the above looks like using the OpenAI chat completions API, which has been widely imitated by other providers:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl https://api.openai.com/v1/chat/completions \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$OPENAI_API_KEY&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;Content-Type: application/json&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  -d &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{&lt;/span&gt;
&lt;span class="pl-s"&gt;    "model": "gpt-5.5",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "messages": [&lt;/span&gt;
&lt;span class="pl-s"&gt;      {&lt;/span&gt;
&lt;span class="pl-s"&gt;        "role": "user",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "content": "Capital of France?"&lt;/span&gt;
&lt;span class="pl-s"&gt;      },&lt;/span&gt;
&lt;span class="pl-s"&gt;      {&lt;/span&gt;
&lt;span class="pl-s"&gt;        "role": "assistant",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "content": "Paris"&lt;/span&gt;
&lt;span class="pl-s"&gt;      },&lt;/span&gt;
&lt;span class="pl-s"&gt;      {&lt;/span&gt;
&lt;span class="pl-s"&gt;        "role": "user",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "content": "Germany?"&lt;/span&gt;
&lt;span class="pl-s"&gt;      }&lt;/span&gt;
&lt;span class="pl-s"&gt;    ]&lt;/span&gt;
&lt;span class="pl-s"&gt;  }&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Prior to 0.32, LLM modeled these as conversations:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;conversation&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;conversation&lt;/span&gt;()
&lt;span class="pl-s1"&gt;r1&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;conversation&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Capital of France?"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;r1&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())
&lt;span class="pl-c"&gt;# Outputs "Paris"&lt;/span&gt;

&lt;span class="pl-s1"&gt;r2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;conversation&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Germany?"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;r2&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())
&lt;span class="pl-c"&gt;# Outputs "Berlin"&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This worked if you were building a conversation with the model from scratch, but it didn't provide a way to feed in a previous conversation from the start. This made tasks like building an emulation of the OpenAI chat completions API much harder than they should have been.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;llm&lt;/code&gt; CLI tool worked around this through a custom mechanism for persisting and inflating conversations using SQLite, but that never became a stable part of the LLM API - and there are many places you might want to use the Python library without committing to SQLite as the storage layer.&lt;/p&gt;
&lt;p&gt;The new alpha now supports this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;user&lt;/span&gt;, &lt;span class="pl-s1"&gt;assistant&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s1"&gt;messages&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[
    &lt;span class="pl-en"&gt;user&lt;/span&gt;(&lt;span class="pl-s"&gt;"Capital of France?"&lt;/span&gt;),
    &lt;span class="pl-en"&gt;assistant&lt;/span&gt;(&lt;span class="pl-s"&gt;"Paris"&lt;/span&gt;),
    &lt;span class="pl-en"&gt;user&lt;/span&gt;(&lt;span class="pl-s"&gt;"Germany?"&lt;/span&gt;),
])
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;llm.user()&lt;/code&gt; and &lt;code&gt;llm.assistant()&lt;/code&gt; functions are new builder functions designed to be used within that &lt;code&gt;messages=[]&lt;/code&gt; array.&lt;/p&gt;
&lt;p&gt;The previous &lt;code&gt;prompt=&lt;/code&gt; option still works, but LLM upgrades it to a single-item messages array behind the scenes.&lt;/p&gt;
&lt;p&gt;You can also now &lt;em&gt;reply&lt;/em&gt; to a response, as an alternative to building a conversation:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;response2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;reply&lt;/span&gt;(&lt;span class="pl-s"&gt;"How about Hungary?"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response2&lt;/span&gt;) &lt;span class="pl-c"&gt;# Default __str__() calls .text()&lt;/span&gt;&lt;/pre&gt;
&lt;h4 id="streaming-parts"&gt;Streaming parts&lt;/h4&gt;
&lt;p&gt;The other major new interface in the alpha concerns streaming results back from a prompt.&lt;/p&gt;
&lt;p&gt;Previously, LLM supported streaming like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Generate an SVG of a pelican riding a bicycle"&lt;/span&gt;)
&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;chunk&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;:
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;""&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Or this async variant:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;asyncio&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_async_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Generate an SVG of a pelican riding a bicycle"&lt;/span&gt;)

&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;run&lt;/span&gt;():
    &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;chunk&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;:
        &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&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-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

&lt;span class="pl-s1"&gt;asyncio&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-en"&gt;run&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;Many of today's models return mixed types of content. A prompt run against Claude might return reasoning output, then text, then a JSON request for a tool call, then more text content.&lt;/p&gt;
&lt;p&gt;Some models can even execute tools on the server-side, for example OpenAI's &lt;a href="https://developers.openai.com/api/docs/guides/tools-code-interpreter?lang=curl"&gt;code interpreter tool&lt;/a&gt; or Anthropic's &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool"&gt;web search&lt;/a&gt;. This means the results from the model can combine text, tool calls, tool outputs and other formats.&lt;/p&gt;
&lt;p&gt;Multi-modal output models are starting to emerge too, which can return images or even &lt;a href="https://developers.openai.com/api/docs/guides/audio#add-audio-to-your-existing-application"&gt;snippets of audio&lt;/a&gt; intermixed into that streaming response.&lt;/p&gt;
&lt;p&gt;The new LLM alpha models these as a stream of typed message parts. Here's what that looks like as a Python API consumer:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;asyncio&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;prompt&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"invent 3 cool dogs, first talk about your motivations"&lt;/span&gt;

&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;describe_dog&lt;/span&gt;(&lt;span class="pl-s1"&gt;name&lt;/span&gt;: &lt;span class="pl-smi"&gt;str&lt;/span&gt;, &lt;span class="pl-s1"&gt;bio&lt;/span&gt;: &lt;span class="pl-smi"&gt;str&lt;/span&gt;) &lt;span class="pl-c1"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="pl-smi"&gt;str&lt;/span&gt;:
    &lt;span class="pl-s"&gt;"""Record the name and biography of a hypothetical dog."""&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s"&gt;f"&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;name&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;bio&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;"&lt;/span&gt;

&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;sync_example&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;prompt&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;tools&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s1"&gt;describe_dog&lt;/span&gt;],
    )
    &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;stream_events&lt;/span&gt;():
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"text"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&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-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
        &lt;span class="pl-k"&gt;elif&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"tool_call_name"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s"&gt;f"&lt;span class="pl-cce"&gt;\n&lt;/span&gt;Tool call: &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;("&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&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-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
        &lt;span class="pl-k"&gt;elif&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"tool_call_args"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&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-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;async_example&lt;/span&gt;():
    &lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_async_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-5.5"&lt;/span&gt;)
    &lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(
        &lt;span class="pl-s1"&gt;prompt&lt;/span&gt;,
        &lt;span class="pl-s1"&gt;tools&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s1"&gt;describe_dog&lt;/span&gt;],
    )
    &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;astream_events&lt;/span&gt;():
        &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"text"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&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-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
        &lt;span class="pl-k"&gt;elif&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"tool_call_name"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s"&gt;f"&lt;span class="pl-cce"&gt;\n&lt;/span&gt;Tool call: &lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;("&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&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-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
        &lt;span class="pl-k"&gt;elif&lt;/span&gt; &lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;type&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;"tool_call_args"&lt;/span&gt;:
            &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;event&lt;/span&gt;.&lt;span class="pl-c1"&gt;chunk&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&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-s1"&gt;flush&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)

&lt;span class="pl-en"&gt;sync_example&lt;/span&gt;()
&lt;span class="pl-s1"&gt;asyncio&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-en"&gt;async_example&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;Sample output (from just the first sync example):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;My motivation: create three memorable dogs with distinct “cool” styles—one cinematic, one adventurous, and one charmingly chaotic—so each feels like they could star in their own story.&lt;/code&gt;&lt;br /&gt;
&lt;code&gt;Tool call: describe_dog({"name": "Nova Jetpaw", "bio": "A sleek silver-gray whippet who wears tiny aviator goggles and loves sprinting along moonlit beaches. Nova is fearless, elegant, and rumored to outrun drones just for fun."}&lt;/code&gt;&lt;br /&gt;
&lt;code&gt;Tool call: describe_dog({"name": "Mochi Thunderbark", "bio": "A fluffy corgi with a dramatic black-and-gold bandana and the confidence of a rock star. Mochi is short, loud, loyal, and leads a neighborhood 'security patrol' made entirely of squirrels."}&lt;/code&gt;&lt;br /&gt;
&lt;code&gt;Tool call: describe_dog({"name": "Atlas Snowfang", "bio": "A massive white husky with ice-blue eyes and a backpack full of trail snacks. Atlas is calm, heroic, and always knows the way home—even during blizzards, fog, or confusing camping trips."}&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At the end of the response you can call &lt;code&gt;response.execute_tool_calls()&lt;/code&gt; to actually run the functions that were requested, or send a &lt;code&gt;response.reply()&lt;/code&gt; to have those tools called and their return values sent back to the model:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;reply&lt;/span&gt;(&lt;span class="pl-s"&gt;"Tell me about the dogs"&lt;/span&gt;))&lt;/pre&gt;
&lt;p&gt;This new mechanism for streaming different token types means the CLI tool can now display "thinking" text in a different color from the text in the final response. The thinking text goes to stderr so it won't affect results that are piped into other tools.&lt;/p&gt;
&lt;p&gt;This example uses Claude Sonnet 4.6 (with an updated streaming event version of the &lt;a href="https://github.com/simonw/llm-anthropic"&gt;llm-anthropic&lt;/a&gt; plugin) as Anthropic's models return their reasoning text as part of the response:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm -m claude-sonnet-4.6 &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Think about 3 cool dogs then describe them&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; \
  -o thinking_display 1&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/claude-thinking-llm.gif" alt="Animated demo. Starts with ~/dev/scratch/llm-anthropic % uv run llm -m claude-sonnet-4.6 'Think about 3 cool dogs then describe them' -o thinking_display 1 - the text then streams in grey: The user wants me to think about 3 cool dogs and then describe them. Let me come up with 3 interesting, cool dogs and describe them. Then switches to regular color text for the output that describes the dogs." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;You can suppress the output of reasoning tokens using the new &lt;code&gt;-R/--no-reasoning&lt;/code&gt; flag. Surprisingly that ended up being the only CLI-facing change in this release.&lt;/p&gt;
&lt;h4 id="a-mechanism-for-serializing-and-deserializing-responses"&gt;A mechanism for serializing and deserializing responses&lt;/h4&gt;
&lt;p&gt;As mentioned earlier, LLM has quite inflexible code at the moment for persisting conversations to SQLite. I've added a new mechanism in 0.32a0 that should provide Python API users a way to roll their own alternative:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;serializable&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;to_dict&lt;/span&gt;()
&lt;span class="pl-c"&gt;# serializable is a JSON-style dictionary&lt;/span&gt;
&lt;span class="pl-c"&gt;# store it anywhere you like, then inflate it:&lt;/span&gt;
&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Response&lt;/span&gt;.&lt;span class="pl-c1"&gt;from_dict&lt;/span&gt;(&lt;span class="pl-s1"&gt;serializable&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;The dictionary this returns is actually a &lt;code&gt;TypedDict&lt;/code&gt; defined in the new &lt;a href="https://github.com/simonw/llm/blob/main/llm/serialization.py"&gt;llm/serialization.py&lt;/a&gt; module.&lt;/p&gt;
&lt;h4 id="what-s-next-"&gt;What's next?&lt;/h4&gt;
&lt;p&gt;I'm releasing this as an alpha so I can upgrade various plugins and exercise the new design in real world environments for a few days. I expect the stable 0.32 release will be very similar to this alpha, unless alpha testing reveals some design flaw in the way I've put this all together.&lt;/p&gt;
&lt;p&gt;There's one remaining large task: I'd like to redesign the SQLite logging system to better capture the more finely grained details that are returned by this new abstraction.&lt;/p&gt;
&lt;p&gt;Ideally I'd like to model this as a graph, to best support situations like an OpenAI-style chat completions API where the same conversations are constantly extended and then repeated with every prompt. I want to be able to store those without duplicating them in the database.&lt;/p&gt;
&lt;p&gt;I'm undecided as to whether that should be a feature in 0.32 or I should hold it for 0.33.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="python"/><category term="ai"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm"/></entry><entry><title>datasette 1.0a27</title><link href="https://simonwillison.net/2026/Apr/15/datasette/#atom-tag" rel="alternate"/><published>2026-04-15T23:16:34+00:00</published><updated>2026-04-15T23:16:34+00:00</updated><id>https://simonwillison.net/2026/Apr/15/datasette/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/datasette/releases/tag/1.0a27"&gt;datasette 1.0a27&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Two major changes in this new Datasette alpha. I covered the first of those &lt;a href="https://simonwillison.net/2026/Apr/14/replace-token-based-csrf/"&gt;in detail yesterday&lt;/a&gt; - Datasette no longer uses Django-style CSRF form tokens, instead using modern browser headers &lt;a href="https://words.filippo.io/csrf"&gt;as described by Filippo Valsorda&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The second big change is that Datasette now fires a new &lt;a href="https://docs.datasette.io/en/latest/events.html#datasette.events.RenameTableEvent"&gt;RenameTableEvent&lt;/a&gt; any time a table is renamed during a SQLite transaction. This is useful because some plugins (like &lt;a href="https://github.com/datasette/datasette-comments"&gt;datasette-comments&lt;/a&gt;) attach additional data to table records by name, so a renamed table requires them to react in appropriate ways.&lt;/p&gt;
&lt;p&gt;Here are the rest of the changes in the alpha:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-datasette-client-actor"&gt;actor= parameter&lt;/a&gt; for &lt;code&gt;datasette.client&lt;/code&gt; methods, allowing internal requests to be made as a specific actor. This is particularly useful for writing automated tests. (&lt;a href="https://github.com/simonw/datasette/pull/2688"&gt;#2688&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;Database(is_temp_disk=True)&lt;/code&gt; option, used internally for the internal database. This helps resolve intermittent database locked errors caused by the internal database being in-memory as opposed to on-disk. (&lt;a href="https://github.com/simonw/datasette/issues/2683"&gt;#2683&lt;/a&gt;) (&lt;a href="https://github.com/simonw/datasette/pull/2684"&gt;#2684&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/&amp;lt;database&amp;gt;/&amp;lt;table&amp;gt;/-/upsert&lt;/code&gt; API (&lt;a href="https://docs.datasette.io/en/latest/json_api.html#tableupsertview"&gt;docs&lt;/a&gt;) now rejects rows with &lt;code&gt;null&lt;/code&gt; primary key values. (&lt;a href="https://github.com/simonw/datasette/issues/1936"&gt;#1936&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Improved example in the API explorer for the &lt;code&gt;/-/upsert&lt;/code&gt; endpoint (&lt;a href="https://docs.datasette.io/en/latest/json_api.html#tableupsertview"&gt;docs&lt;/a&gt;). (&lt;a href="https://github.com/simonw/datasette/issues/1936"&gt;#1936&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/&amp;lt;database&amp;gt;.json&lt;/code&gt; endpoint now includes an &lt;code&gt;"ok": true&lt;/code&gt; key, for consistency with other JSON API responses.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-utils-call-with-supported-arguments"&gt;call_with_supported_arguments()&lt;/a&gt; is now documented as a supported public API. (&lt;a href="https://github.com/simonw/datasette/pull/2678"&gt;#2678&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&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="python"/><category term="datasette"/><category term="annotated-release-notes"/></entry><entry><title>datasette-llm 0.1a1</title><link href="https://simonwillison.net/2026/Mar/25/datasette-llm/#atom-tag" rel="alternate"/><published>2026-03-25T21:24:31+00:00</published><updated>2026-03-25T21:24:31+00:00</updated><id>https://simonwillison.net/2026/Mar/25/datasette-llm/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/datasette/datasette-llm/releases/tag/0.1a1"&gt;datasette-llm 0.1a1&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;New release of the base plugin that makes models from &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; available for use by other Datasette plugins such as &lt;a href="https://github.com/datasette/datasette-enrichments-llm"&gt;datasette-enrichments-llm&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;a href="https://github.com/datasette/datasette-llm/blob/main/README.md#register_llm_purposes"&gt;&lt;code&gt;register_llm_purposes()&lt;/code&gt; plugin hook&lt;/a&gt; and &lt;code&gt;get_purposes()&lt;/code&gt; function for retrieving registered purpose strings. &lt;a href="https://github.com/datasette/datasette-llm/issues/1"&gt;#1&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;One of the responsibilities of this plugin is to configure which models are used for which purposes, so you can say in one place "data enrichment uses GPT-5.4-nano but SQL query assistance happens using Sonnet 4.6", for example.&lt;/p&gt;
&lt;p&gt;Plugins that depend on this can use &lt;code&gt;model = await llm.model(purpose="enrichment")&lt;/code&gt; to indicate the purpose of the prompts they wish to execute against the model. Those plugins can now also use the new &lt;code&gt;register_llm_purposes()&lt;/code&gt; hook to register those purpose strings, which means future plugins can list those purposes in one place to power things like an admin UI for assigning models to purposes.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="plugins"/><category term="datasette"/><category term="annotated-release-notes"/><category term="llm"/></entry><entry><title>datasette-files 0.1a2</title><link href="https://simonwillison.net/2026/Mar/23/datasette-files/#atom-tag" rel="alternate"/><published>2026-03-23T23:06:38+00:00</published><updated>2026-03-23T23:06:38+00:00</updated><id>https://simonwillison.net/2026/Mar/23/datasette-files/#atom-tag</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/datasette/datasette-files/releases/tag/0.1a2"&gt;datasette-files 0.1a2&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;The most interesting alpha of &lt;a href="https://github.com/datasette/datasette-files"&gt;datasette-files&lt;/a&gt; yet, a new plugin which adds the ability to upload files directly into a Datasette instance. Here are the release notes in full:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Columns are now configured using the &lt;a href="https://docs.datasette.io/en/latest/changelog.html#new-column-types-system"&gt;new column_types system&lt;/a&gt; from Datasette 1.0a26. &lt;a href="https://github.com/datasette/datasette-files/issues/8"&gt;#8&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;file_actions&lt;/code&gt; plugin hook, plus ability to import an uploaded CSV/TSV file to a table. &lt;a href="https://github.com/datasette/datasette-files/issues/10"&gt;#10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;UI for uploading multiple files at once via the new documented JSON upload API. &lt;a href="https://github.com/datasette/datasette-files/issues/11"&gt;#11&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Thumbnails are now generated for image files and stored in an internal &lt;code&gt;datasette_files_thumbnails&lt;/code&gt; table. &lt;a href="https://github.com/datasette/datasette-files/issues/13"&gt;#13&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="datasette"/><category term="annotated-release-notes"/></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/browsers"&gt;browsers&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/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rodney"&gt;rodney&lt;/a&gt;&lt;/p&gt;



</summary><category term="browsers"/><category term="projects"/><category term="testing"/><category term="annotated-release-notes"/><category term="rodney"/></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/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/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="python"/><category term="datasette"/><category term="annotated-release-notes"/><category term="uv"/></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/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="annotated-release-notes"/><category term="shot-scraper"/></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/aws"&gt;aws&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/ai"&gt;ai&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/s3-credentials"&gt;s3-credentials&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/prompt-engineering"&gt;prompt-engineering&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="aws"/><category term="projects"/><category term="s3"/><category term="ai"/><category term="annotated-release-notes"/><category term="s3-credentials"/><category term="prompt-engineering"/><category term="generative-ai"/><category term="llms"/><category term="coding-agents"/><category term="claude-code"/></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/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="python"/><category term="ai"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="uv"/></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/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/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="uv"/><category term="coding-agents"/><category term="claude-code"/></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/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/sqlite-utils"&gt;sqlite-utils&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="sqlite"/><category term="sqlite-utils"/><category term="annotated-release-notes"/><category term="ai-assisted-programming"/><category term="coding-agents"/><category term="claude-code"/></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"&gt;datasette&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;/p&gt;



</summary><category term="projects"/><category term="datasette"/><category term="datasette-cloud"/><category term="annotated-release-notes"/></entry><entry><title>Open redirect endpoint in Datasette prior to 0.65.2 and 1.0a21</title><link href="https://simonwillison.net/2025/Nov/5/open-redirect-datasette/#atom-tag" rel="alternate"/><published>2025-11-05T23:11:17+00:00</published><updated>2025-11-05T23:11:17+00:00</updated><id>https://simonwillison.net/2025/Nov/5/open-redirect-datasette/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/datasette/security/advisories/GHSA-w832-gg5g-x44m"&gt;Open redirect endpoint in Datasette prior to 0.65.2 and 1.0a21&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
This GitHub security advisory covers two new releases of Datasette that I shipped today, both addressing &lt;a href="https://github.com/simonw/datasette/issues/2429"&gt;the same open redirect issue&lt;/a&gt; with a fix by &lt;a href="https://github.com/jamesjefferies"&gt;James Jefferies&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.datasette.io/en/stable/changelog.html#v0-65-2"&gt;Datasette 0.65.2&lt;/a&gt;&lt;/strong&gt; fixes the bug and also adds Python 3.14 support and a &lt;code&gt;datasette publish cloudrun&lt;/code&gt; fix.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a21-2025-11-05"&gt;Datasette 1.0a21&lt;/a&gt;&lt;/strong&gt; also has that Cloud Run fix and two other small new features:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;datasette --get /path --headers&lt;/code&gt; option for inspecting the headers returned by a path. (&lt;a href="https://github.com/simonw/datasette/issues/2578"&gt;#2578&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;datasette.client.get(..., skip_permission_checks=True)&lt;/code&gt; parameter to bypass permission checks when making requests using the internal client. (&lt;a href="https://github.com/simonw/datasette/issues/2583"&gt;#2583&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I decided to include the Cloud Run deployment fix so anyone with Datasette instances deployed to Cloud Run can update them with the new patched versions.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cloudrun"&gt;cloudrun&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="security"/><category term="datasette"/><category term="cloudrun"/><category term="annotated-release-notes"/></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/plugins"&gt;plugins&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/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/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex"&gt;codex&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="plugins"/><category term="projects"/><category term="python"/><category term="sql"/><category term="sqlite"/><category term="datasette"/><category term="annotated-release-notes"/><category term="uv"/><category term="coding-agents"/><category term="claude-code"/><category term="codex"/></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/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&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/gpt-5"&gt;gpt-5&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt"&gt;gpt&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="python"/><category term="ai"/><category term="datasette"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="llm-tool-use"/><category term="gpt-5"/><category term="gpt"/></entry><entry><title>Long context support in LLM 0.24 using fragments and template plugins</title><link href="https://simonwillison.net/2025/Apr/7/long-context-llm/#atom-tag" rel="alternate"/><published>2025-04-07T17:45:11+00:00</published><updated>2025-04-07T17:45:11+00:00</updated><id>https://simonwillison.net/2025/Apr/7/long-context-llm/#atom-tag</id><summary type="html">
    &lt;p&gt;LLM 0.24 is &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-24"&gt;now available&lt;/a&gt; with new features to help take advantage of the increasingly long input context supported by modern LLMs.&lt;/p&gt;
&lt;p&gt;(&lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt; is my command-line tool and &lt;a href="https://llm.datasette.io/en/stable/python-api.html"&gt;Python library&lt;/a&gt; for interacting with LLMs, supported by &lt;a href="https://llm.datasette.io/en/stable/plugins/directory.html"&gt;20+ plugins&lt;/a&gt; adding support for both local and remote models from a bunch of different providers.)&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#trying-it-out"&gt;Trying it out&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#improving-llm-s-support-for-long-context-models"&gt;Improving LLM's support for long context models&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#asking-questions-of-llm-s-documentation"&gt;Asking questions of LLM's documentation&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#publishing-sharing-and-reusing-templates"&gt;Publishing, sharing and reusing templates&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#template-and-fragment-loader-plugins"&gt;Template and fragment loader plugins so far&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Apr/7/long-context-llm/#everything-else-in-llm-0-24"&gt;Everything else in LLM 0.24&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;h4 id="trying-it-out"&gt;Trying it out&lt;/h4&gt;

&lt;p&gt;To install LLM with &lt;a href="https://github.com/astral-sh/uv"&gt;uv&lt;/a&gt; (there are &lt;a href="https://llm.datasette.io/en/stable/setup.html#installation"&gt;several other options&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv tool install llm&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You'll need to either provide &lt;a href="https://platform.openai.com/api-keys"&gt;an OpenAI API key&lt;/a&gt; or install &lt;a href="https://llm.datasette.io/en/stable/plugins/directory.html"&gt;a plugin&lt;/a&gt; to use local models or  models from other providers:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm keys &lt;span class="pl-c1"&gt;set&lt;/span&gt; openai
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Paste OpenAI API key here&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;To &lt;a href="https://llm.datasette.io/en/stable/setup.html#upgrading-to-the-latest-version"&gt;upgrade LLM&lt;/a&gt; from a previous version:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install -U llm&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The biggest new feature is &lt;strong&gt;&lt;a href="https://llm.datasette.io/en/stable/fragments.html"&gt;fragments&lt;/a&gt;&lt;/strong&gt;. You can now use &lt;code&gt;-f filename&lt;/code&gt; or &lt;code&gt;-f url&lt;/code&gt; to add one or more fragments to your prompt, which means you can do things like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm -f https://simonwillison.net/2025/Apr/5/llama-4-notes/ &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;bullet point summary&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/7ffb19f647603558c575359e678185c0"&gt;the output&lt;/a&gt; from that prompt, exported using &lt;code&gt;llm logs -c --expand --usage&lt;/code&gt;. Token cost was 5,372 input, 374 output which works out as 0.103 cents (around 1/10th of a cent) using the default GPT-4o mini model.&lt;/p&gt;
&lt;p&gt;Plugins can implement custom fragment loaders with a prefix. The &lt;a href="https://github.com/simonw/llm-fragments-github"&gt;llm-fragments-github&lt;/a&gt; plugin adds a &lt;code&gt;github:&lt;/code&gt; prefix that can be used to load every text file in a GitHub repository as a list of fragments:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-fragments-github
llm -f github:simonw/s3-credentials &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Suggest new features for this tool&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/c9bbbc5f6560b01f4b7882ac0194fb25"&gt;the output&lt;/a&gt;. That took 49,856 input tokens for a total cost of 0.7843 cents - nearly a whole cent!&lt;/p&gt;
&lt;h4 id="improving-llm-s-support-for-long-context-models"&gt;Improving LLM's support for long context models&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://simonwillison.net/tags/long-context/"&gt;Long context&lt;/a&gt;&lt;/strong&gt; is one of the most exciting trends in LLMs over the past eighteen months. Saturday's &lt;a href="https://simonwillison.net/2025/Apr/5/llama-4-notes/"&gt;Llama 4 Scout release&lt;/a&gt; gave us the first model with a full 10 million token context. Google's &lt;a href="https://simonwillison.net/tags/gemini/"&gt;Gemini&lt;/a&gt; family has several 1-2 million token models, and the baseline for recent models from both OpenAI and Anthropic is 100 or 200 thousand.&lt;/p&gt;
&lt;p&gt;Two years ago most models capped out at 8,000 tokens of input. Long context opens up many new interesting ways to apply this class of technology.&lt;/p&gt;
&lt;p&gt;I've been using long context models via &lt;a href="https://simonwillison.net/tags/files-to-prompt/"&gt;my files-to-prompt tool&lt;/a&gt; to summarize large codebases, explain how they work and even to &lt;a href="https://simonwillison.net/2024/Sep/25/o1-preview-llm/"&gt;debug gnarly bugs&lt;/a&gt;. As demonstrated above, it's surprisingly inexpensive to drop tens of thousands of tokens into  models like GPT-4o mini or most of the Google Gemini series, and the results are often very impressive.&lt;/p&gt;
&lt;p&gt;One of LLM's most useful features is that it &lt;a href="https://llm.datasette.io/en/stable/logging.html"&gt;logs every prompt and response&lt;/a&gt; to a SQLite database. This is great for comparing the same prompt against different models and tracking experiments over time - my own database contained thousands of responses from hundreds of different models accumulated over the past couple of years.&lt;/p&gt;
&lt;p&gt;This is where long context prompts were starting to be a problem. Since LLM stores the full prompt and response in the database, asking five questions of the same source code could result in five duplicate copies of that text in the database!&lt;/p&gt;
&lt;p&gt;The new &lt;strong&gt;fragments&lt;/strong&gt; feature targets this problem head on. Each fragment is stored once in a &lt;a href="https://llm.datasette.io/en/stable/logging.html#sql-schema"&gt;fragments&lt;/a&gt; table, then de-duplicated in the future using a SHA256 hash of its content.&lt;/p&gt;
&lt;p&gt;This saves on storage, and also enables features like &lt;code&gt;llm logs -f X&lt;/code&gt; for seeing all logged responses &lt;a href="https://llm.datasette.io/en/stable/logging.html#filtering-by-prompts-that-used-a-specific-fragment"&gt;that use a particular fragment&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Fragments can be specified in several different ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a path to a file&lt;/li&gt;
&lt;li&gt;a URL to data online&lt;/li&gt;
&lt;li&gt;an alias that's been set against a previous fragment (see &lt;a href="https://llm.datasette.io/en/latest/help.html#llm-fragments-set-help"&gt;llm fragments set&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;a hash ID of the content of a fragment&lt;/li&gt;
&lt;li&gt;using &lt;code&gt;prefix:argument&lt;/code&gt; to specify fragments from a plugin&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="asking-questions-of-llm-s-documentation"&gt;Asking questions of LLM's documentation&lt;/h4&gt;
&lt;p&gt;Wouldn't it be neat if LLM could answer questions about its own documentation?&lt;/p&gt;
&lt;p&gt;The new &lt;a href="https://github.com/simonw/llm-docs"&gt;llm-docs&lt;/a&gt; plugin (built with the new &lt;a href="https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-fragment-loaders-register"&gt;register_fragment_loaders() plugin hook&lt;/a&gt;) enables exactly that:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-docs
llm -f docs: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;How do I embed a binary file?&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href="https://gist.github.com/simonw/df115ffa63e101833a100f71555bf53d"&gt;output&lt;/a&gt; starts like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;To embed a binary file using the LLM command-line interface, you can use the &lt;code&gt;llm embed&lt;/code&gt; command with the &lt;code&gt;--binary&lt;/code&gt; option. Here’s how you can do it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Make sure you have the appropriate embedding model installed that supports binary input.&lt;/li&gt;
&lt;li&gt;Use the following command syntax:&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm embed -m &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;model_id&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; --binary -i &lt;span class="pl-k"&gt;&amp;lt;&lt;/span&gt;path_to_your_binary_file&lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Replace &lt;code&gt;&amp;lt;model_id&amp;gt;&lt;/code&gt; with the identifier for the embedding model you want to use (e.g., &lt;code&gt;clip&lt;/code&gt; for the CLIP model) and &lt;code&gt;&amp;lt;path_to_your_binary_file&amp;gt;&lt;/code&gt; with the path to your actual binary file.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(74,570 input, 240 output = 1.1329 cents with GPT-4o mini)&lt;/p&gt;
&lt;p&gt;Using &lt;code&gt;-f docs:&lt;/code&gt; with just the prefix is the same as using &lt;code&gt;-f docs:llm&lt;/code&gt;. The plugin fetches the documentation for your current version of LLM from my new &lt;a href="https://github.com/simonw/docs-for-llms"&gt;simonw/docs-for-llms&lt;/a&gt; repo, which also provides packaged documentation files for my &lt;code&gt;datasette&lt;/code&gt;, &lt;code&gt;s3-credentials&lt;/code&gt;, &lt;code&gt;shot-scraper&lt;/code&gt; and &lt;code&gt;sqlite-utils&lt;/code&gt; projects.&lt;/p&gt;
&lt;p&gt;Datasette's documentation has got pretty long, so you might need to run that through a Gemini model instead (using the &lt;a href="https://github.com/simonw/llm-gemini"&gt;llm-gemini plugin&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm -f docs:datasette -m gemini-2.0-flash \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;Build a render_cell plugin that detects and renders markdown&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://gist.github.com/simonw/f08b3832c379ca89d8cff3c61ec5662e"&gt;Here's the output&lt;/a&gt;. 132,042 input, 1,129 output with Gemini 2.0 Flash = 1.3656 cents.&lt;/p&gt;
&lt;p&gt;You can browse the combined documentation files this uses in &lt;a href="https://github.com/simonw/docs-for-llms"&gt;docs-for-llm&lt;/a&gt;. They're built using GitHub Actions.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://llmstxt.org/"&gt;llms-txt&lt;/a&gt; is a project lead by Jeremy Howard that encourages projects to publish similar files to help LLMs ingest a succinct copy of their documentation.&lt;/p&gt;
&lt;h4 id="publishing-sharing-and-reusing-templates"&gt;Publishing, sharing and reusing templates&lt;/h4&gt;
&lt;p&gt;The new &lt;a href="https://llm.datasette.io/en/stable/plugins/plugin-hooks.html#register-template-loaders-register"&gt;register_template_loaders() plugin hook&lt;/a&gt; allows plugins to register &lt;code&gt;prefix:value&lt;/code&gt; custom template loaders, for use with the &lt;code&gt;llm -t&lt;/code&gt; option.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/llm-templates-github"&gt;llm-templates-github&lt;/a&gt; and &lt;a href="https://github.com/simonw/llm-templates-fabric"&gt;llm-templates-fabric&lt;/a&gt; are two new plugins that make use of that hook.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;llm-templates-github&lt;/code&gt; lets you share and use templates via a public GitHub repository. Here's how to run my &lt;a href="https://simonwillison.net/tags/pelican-riding-a-bicycle/"&gt;Pelican riding a bicycle&lt;/a&gt; benchmark against a specific model:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-templates-github
llm -t gh:simonw/pelican-svg -m o3-mini&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This executes &lt;a href="https://github.com/simonw/llm-templates/blob/main/pelican-svg.yaml"&gt;this pelican-svg.yaml&lt;/a&gt; template stored in my &lt;a href="https://github.com/simonw/llm-templates"&gt;simonw/llm-templates&lt;/a&gt; repository, using a new repository naming convention.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;llm -t gh:simonw/pelican-svg&lt;/code&gt; will load that &lt;code&gt;pelican-svg.yaml&lt;/code&gt; file from the &lt;code&gt;simonw/llm-templates&lt;/code&gt; repo. You can also use &lt;code&gt;llm -t gh:simonw/name-of-repo/name-of-template&lt;/code&gt; to load a template from a repository that doesn't follow that convention.&lt;/p&gt;
&lt;p&gt;To share your own templates, create a repository on GitHub under your user account called &lt;code&gt;llm-templates&lt;/code&gt; and start saving &lt;code&gt;.yaml&lt;/code&gt; files to it.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/llm-templates-fabric"&gt;llm-templates-fabric&lt;/a&gt; provides a similar mechanism for loading templates from  Daniel Miessler's extensive &lt;a href="https://github.com/danielmiessler/fabric"&gt;fabric collection&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install llm-templates-fabric
curl https://simonwillison.net/2025/Apr/6/only-miffy/ &lt;span class="pl-k"&gt;|&lt;/span&gt; \
  llm -t f:extract_main_idea&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;A conversation with Daniel was the inspiration for this new plugin hook.&lt;/p&gt;

&lt;h4 id="template-and-fragment-loader-plugins"&gt;Template and fragment loader plugins so far&lt;/h4&gt;
&lt;p&gt;Here's the list of fragment and template loader plugins so far from the &lt;a href="https://llm.datasette.io/en/stable/plugins/directory.html#fragments-and-template-loaders"&gt;LLM plugins directory&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-templates-github"&gt;llm-templates-github&lt;/a&gt;&lt;/strong&gt; supports loading templates shared on GitHub, e.g. &lt;code&gt;llm -t gh:simonw/pelican-svg&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-templates-fabric"&gt;llm-templates-fabric&lt;/a&gt;&lt;/strong&gt; provides access to the &lt;a href="https://github.com/danielmiessler/fabric"&gt;Fabric&lt;/a&gt; collection of prompts: &lt;code&gt;cat setup.py | llm -t fabric:explain_code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-fragments-github"&gt;llm-fragments-github&lt;/a&gt;&lt;/strong&gt; can load entire GitHub repositories in a single operation: &lt;code&gt;llm -f github:simonw/files-to-prompt 'explain this code'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-hacker-news"&gt;llm-hacker-news&lt;/a&gt;&lt;/strong&gt; imports conversations from Hacker News as fragments: &lt;code&gt;llm -f hn:43615912 'summary with illustrative direct quotes'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h4 id="everything-else-in-llm-0-24"&gt;Everything else in LLM 0.24&lt;/h4&gt;
&lt;p&gt;LLM 0.24 is a big release, spanning &lt;a href="https://github.com/simonw/llm/compare/0.23...0.24"&gt;51 commits&lt;/a&gt;. The &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-24"&gt;release notes&lt;/a&gt; cover everything that's new in full - here are a few of my highlights:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The new &lt;a href="https://github.com/simonw/llm-openai-plugin"&gt;llm-openai plugin&lt;/a&gt; provides support for &lt;strong&gt;o1-pro&lt;/strong&gt; (which is not supported by the OpenAI mechanism used by LLM core). Future OpenAI features will migrate to this plugin instead of LLM core itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The problem with OpenAI models being handled by LLM core is that I have to release a whole new version of LLM every time OpenAI releases a new model or feature. Migrating this stuff out to a plugin means I can release new version of that plugin independently of LLM itself - something I frequently do for &lt;a href="https://github.com/simonw/llm-anthropic"&gt;llm-anthropic&lt;/a&gt; and &lt;a href="https://github.com/simonw/llm-gemini"&gt;llm-gemini&lt;/a&gt; and others.&lt;/p&gt;
&lt;p&gt;The new &lt;code&gt;llm-openai&lt;/code&gt; plugin uses their Responses API, a new shape of API which &lt;a href="https://simonwillison.net/2025/Mar/11/responses-vs-chat-completions/"&gt;I covered last month&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm -t $URL&lt;/code&gt; option can now take a URL to a YAML template. &lt;a href="https://github.com/simonw/llm/issues/856"&gt;#856&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The new custom template loaders are fun, but being able to paste in a URL to a YAML file somewhere provides a simpler way to share templates.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Templates can now store default model options. &lt;a href="https://github.com/simonw/llm/issues/845"&gt;#845&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Attachments can now be stored in templates. &lt;a href="https://github.com/simonw/llm/issues/826"&gt;#826&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The quickest way to create your own template is with the &lt;code&gt;llm prompt ... --save name-of-template&lt;/code&gt; command. This now works with attachments, fragments and default model options, each of which is persisted in &lt;a href="https://llm.datasette.io/en/stable/templates.html#templates-as-yaml-files"&gt;the template YAML file&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;a href="https://llm.datasette.io/en/stable/usage.html#setting-default-options-for-models"&gt;llm models options&lt;/a&gt; family of commands for setting default options for particular models. &lt;a href="https://github.com/simonw/llm/issues/829"&gt;#829&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I built this when I &lt;a href="https://twitter.com/bindureddy/status/1900345517256958140"&gt;learned that&lt;/a&gt; Qwen's QwQ-32b model works best with temperature 0.7 and top p 0.95.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm prompt -d path-to-sqlite.db&lt;/code&gt; option can now be used to write logs to a custom SQLite database. &lt;a href="https://github.com/simonw/llm/issues/858"&gt;#858&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This proved extremely useful for testing fragments - it meant I could run a prompt and save the full response to a separate SQLite database which I could then upload to S3 and share &lt;a href="https://github.com/simonw/llm/issues/863#issuecomment-2781723372"&gt;as a link to Datasette Lite&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm similar -p/--plain&lt;/code&gt; option providing more human-readable output than the default JSON. &lt;a href="https://github.com/simonw/llm/issues/853"&gt;#853&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I'd like this to be the default output, but I'm holding off on changing that until LLM 1.0 since it's a breaking change for people building automations against the JSON from &lt;code&gt;llm similar&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Set the &lt;code&gt;LLM_RAISE_ERRORS=1&lt;/code&gt; environment variable to raise errors during prompts rather than suppressing them, which means you can run &lt;code&gt;python -i -m llm 'prompt'&lt;/code&gt; and then drop into a debugger on errors with &lt;code&gt;import pdb; pdb.pm()&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/817"&gt;#817&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Really useful for debugging new model plugins.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;llm prompt -q gpt -q 4o&lt;/code&gt; option - pass &lt;code&gt;-q searchterm&lt;/code&gt; one or more times to execute a prompt against the first model that matches all of those strings - useful for if you can't remember the full model ID. &lt;a href="https://github.com/simonw/llm/issues/841"&gt;#841&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Pretty obscure but I found myself needing this. Vendors love releasing models with names like &lt;code&gt;gemini-2.5-pro-exp-03-25&lt;/code&gt;, now I can run &lt;code&gt;llm -q gem -q 2.5 -q exp 'say hi'&lt;/code&gt; to save me from looking up the model ID.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://llm.datasette.io/en/stable/other-models.html#openai-compatible-models"&gt;OpenAI compatible models&lt;/a&gt; configured using &lt;code&gt;extra-openai-models.yaml&lt;/code&gt; now support &lt;code&gt;supports_schema: true&lt;/code&gt;, &lt;code&gt;vision: true&lt;/code&gt; and &lt;code&gt;audio: true&lt;/code&gt; options. Thanks &lt;a href="https://github.com/adaitche"&gt;@adaitche&lt;/a&gt; and &lt;a href="https://github.com/giuli007"&gt;@giuli007&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/pull/819"&gt;#819&lt;/a&gt;, &lt;a href="https://github.com/simonw/llm/pull/843"&gt;#843&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I don't use this feature myself but it's clearly popular, this isn't the first time I'e had PRs with improvements from the wider community.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&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/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gemini"&gt;gemini&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/long-context"&gt;long-context&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/files-to-prompt"&gt;files-to-prompt&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="plugins"/><category term="projects"/><category term="ai"/><category term="annotated-release-notes"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="gemini"/><category term="long-context"/><category term="files-to-prompt"/></entry><entry><title>shot-scraper 1.8</title><link href="https://simonwillison.net/2025/Mar/25/shot-scraper/#atom-tag" rel="alternate"/><published>2025-03-25T01:59:38+00:00</published><updated>2025-03-25T01:59:38+00:00</updated><id>https://simonwillison.net/2025/Mar/25/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.8"&gt;shot-scraper 1.8&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I've added a new feature to &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; that makes it easier to share scripts for other people to use with the &lt;a href="https://shot-scraper.datasette.io/en/stable/javascript.html"&gt;shot-scraper javascript&lt;/a&gt; command.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;shot-scraper javascript&lt;/code&gt; lets you load up a web page in an invisible Chrome browser (via Playwright), execute some JavaScript against that page and output the results to your terminal. It's a fun way of running complex screen-scraping routines as part of a terminal session, or even chained together with other commands using pipes.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;-i/--input&lt;/code&gt; option lets you load that JavaScript from a file on disk - but now you can also use a &lt;code&gt;gh:&lt;/code&gt; prefix to specify loading code from GitHub instead.&lt;/p&gt;
&lt;p&gt;To quote &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.8"&gt;the release notes&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;shot-scraper javascript&lt;/code&gt; can now optionally &lt;a href="https://shot-scraper.datasette.io/en/stable/javascript.html#running-javascript-from-github"&gt;load scripts hosted on GitHub&lt;/a&gt; via the new &lt;code&gt;gh:&lt;/code&gt; prefix to the &lt;code&gt;shot-scraper javascript -i/--input&lt;/code&gt; option. &lt;a href="https://github.com/simonw/shot-scraper/issues/173"&gt;#173&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Scripts can be referenced as &lt;code&gt;gh:username/repo/path/to/script.js&lt;/code&gt; or, if the GitHub user has created a dedicated &lt;code&gt;shot-scraper-scripts&lt;/code&gt; repository and placed scripts in the root of it, using &lt;code&gt;gh:username/name-of-script&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For example, to run this &lt;a href="https://github.com/simonw/shot-scraper-scripts/blob/main/readability.js"&gt;readability.js&lt;/a&gt; script against any web page you can use the following:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shot-scraper javascript --input gh:simonw/readability \
  https://simonwillison.net/2025/Mar/24/qwen25-vl-32b/
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;a href="https://gist.github.com/simonw/60e196ec39a5a75dcabfd75fbe911a4c"&gt;output from that example&lt;/a&gt; starts like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
    &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Qwen2.5-VL-32B: Smarter and Lighter&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"byline"&lt;/span&gt;: &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;,
    &lt;span class="pl-ent"&gt;"dir"&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"lang"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;en-gb&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"content"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&amp;lt;div id=&lt;span class="pl-cce"&gt;\"&lt;/span&gt;readability-page-1&lt;span class="pl-cce"&gt;\"...&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;My &lt;a href="https://github.com/simonw/shot-scraper-scripts"&gt;simonw/shot-scraper-scripts&lt;/a&gt; repo only has that one file in it so far, but I'm looking forward to growing that collection and hopefully seeing other people create and share their own &lt;code&gt;shot-scraper-scripts&lt;/code&gt; repos as well.&lt;/p&gt;
&lt;p&gt;This feature is an imitation of &lt;a href="https://github.com/simonw/llm/issues/809"&gt;a similar feature&lt;/a&gt; that's coming in the next release of LLM.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&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/scraping"&gt;scraping&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/playwright"&gt;playwright&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="javascript"/><category term="projects"/><category term="scraping"/><category term="annotated-release-notes"/><category term="playwright"/><category term="shot-scraper"/></entry><entry><title>llm-openrouter 0.4</title><link href="https://simonwillison.net/2025/Mar/10/llm-openrouter-04/#atom-tag" rel="alternate"/><published>2025-03-10T21:40:56+00:00</published><updated>2025-03-10T21:40:56+00:00</updated><id>https://simonwillison.net/2025/Mar/10/llm-openrouter-04/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/llm-openrouter/releases/tag/0.4"&gt;llm-openrouter 0.4&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I found out this morning that &lt;a href="https://openrouter.ai/"&gt;OpenRouter&lt;/a&gt; include support for a number of (rate-limited) &lt;a href="https://openrouter.ai/models?max_price=0"&gt;free API models&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I occasionally run workshops on top of LLMs (&lt;a href="https://simonwillison.net/2025/Mar/8/cutting-edge-web-scraping/"&gt;like this one&lt;/a&gt;) and being able to provide students with a quick way to obtain an API key against models where they don't have to setup billing is really valuable to me!&lt;/p&gt;
&lt;p&gt;This inspired me to upgrade my existing &lt;a href="https://github.com/simonw/llm-openrouter"&gt;llm-openrouter&lt;/a&gt; plugin, and in doing so I closed out a bunch of open feature requests.&lt;/p&gt;
&lt;p&gt;Consider this post the &lt;a href="https://simonwillison.net/tags/annotated-release-notes/"&gt;annotated release notes&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;LLM &lt;a href="https://llm.datasette.io/en/stable/schemas.html"&gt;schema support&lt;/a&gt; for OpenRouter models that &lt;a href="https://openrouter.ai/models?order=newest&amp;amp;supported_parameters=structured_outputs"&gt;support structured output&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm-openrouter/issues/23"&gt;#23&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I'm trying to get support for LLM's &lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/"&gt;new schema feature&lt;/a&gt; into as many plugins as possible.&lt;/p&gt;
&lt;p&gt;OpenRouter's OpenAI-compatible API includes support for the &lt;code&gt;response_format&lt;/code&gt; &lt;a href="https://openrouter.ai/docs/features/structured-outputs"&gt;structured content option&lt;/a&gt;, but with an important caveat: it only works for some models, and if you try to use it on others it is silently ignored.&lt;/p&gt;
&lt;p&gt;I &lt;a href="https://github.com/OpenRouterTeam/openrouter-examples/issues/20"&gt;filed an issue&lt;/a&gt; with OpenRouter requesting they include schema support in their machine-readable model index. For the moment LLM will let you specify schemas for unsupported models and will ignore them entirely, which isn't ideal.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;llm openrouter key&lt;/code&gt; command displays information about your current API key. &lt;a href="https://github.com/simonw/llm-openrouter/issues/24"&gt;#24&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Useful for debugging and checking the details of your key's rate limit.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;llm -m ... -o online 1&lt;/code&gt; enables &lt;a href="https://openrouter.ai/docs/features/web-search"&gt;web search grounding&lt;/a&gt; against any model, powered by &lt;a href="https://exa.ai/"&gt;Exa&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm-openrouter/issues/25"&gt;#25&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;OpenRouter apparently make this feature available to every one of their supported models! They're using new-to-me &lt;a href="https://exa.ai/"&gt;Exa&lt;/a&gt; to power this feature, an AI-focused search engine startup who appear to have built their own index with their own crawlers (according to &lt;a href="https://docs.exa.ai/reference/faqs#how-often-is-the-index-updated"&gt;their FAQ&lt;/a&gt;). This feature is currently priced by OpenRouter at $4 per 1000 results, and since 5 results are returned for every prompt that's 2 cents per prompt.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;llm openrouter models&lt;/code&gt; command for listing details of the OpenRouter models, including a &lt;code&gt;--json&lt;/code&gt; option to get JSON and a &lt;code&gt;--free&lt;/code&gt; option to filter for just the free models. &lt;a href="https://github.com/simonw/llm-openrouter/issues/26"&gt;#26&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This offers a neat way to list the available models. There are examples of the output &lt;a href="https://github.com/simonw/llm-openrouter/issues/26#issuecomment-2711908704"&gt;in the comments on the issue&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New option to specify custom provider routing: &lt;code&gt;-o provider '{JSON here}'&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm-openrouter/issues/17"&gt;#17&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Part of OpenRouter's USP is that it can route prompts to different providers depending on factors like latency, cost or as a fallback if your first choice is unavailable - great for if you are using open weight models like Llama which are hosted by competing companies.&lt;/p&gt;
&lt;p&gt;The options they provide for routing are &lt;a href="https://openrouter.ai/docs/features/provider-routing"&gt;very thorough&lt;/a&gt; - I had initially hoped to provide a set of CLI options that covered all of these bases, but I decided instead to reuse their JSON format and forward those options directly on to the model.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/cli"&gt;cli&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/plugins"&gt;plugins&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/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openrouter"&gt;openrouter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-search"&gt;ai-assisted-search&lt;/a&gt;&lt;/p&gt;



</summary><category term="cli"/><category term="plugins"/><category term="projects"/><category term="ai"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="openrouter"/><category term="ai-assisted-search"/></entry><entry><title>Structured data extraction from unstructured content using LLM schemas</title><link href="https://simonwillison.net/2025/Feb/28/llm-schemas/#atom-tag" rel="alternate"/><published>2025-02-28T17:07:07+00:00</published><updated>2025-02-28T17:07:07+00:00</updated><id>https://simonwillison.net/2025/Feb/28/llm-schemas/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-23"&gt;LLM 0.23&lt;/a&gt; is out today, and the signature feature is support for &lt;strong&gt;&lt;a href="https://llm.datasette.io/en/stable/schemas.html"&gt;schemas&lt;/a&gt;&lt;/strong&gt; - a new way of providing structured output from a model that matches a specification provided by the user. I've also upgraded both the &lt;a href="https://github.com/simonw/llm-anthropic"&gt;llm-anthropic&lt;/a&gt; and &lt;a href="https://github.com/simonw/llm-gemini"&gt;llm-gemini&lt;/a&gt; plugins to add support for  schemas.&lt;/p&gt;
&lt;p&gt;TLDR: you can now do things like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm --schema &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;name,age int,short_bio&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;invent a cool dog&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And get back:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&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;Zylo&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"age"&lt;/span&gt;: &lt;span class="pl-c1"&gt;4&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"short_bio"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Zylo is a unique hybrid breed, a mix between a Siberian Husky and a Corgi. With striking blue eyes and a fluffy, colorful coat that changes shades with the seasons, Zylo embodies the spirit of winter and summer alike. Known for his playful personality and intelligence, Zylo can perform a variety of tricks and loves to fetch his favorite frisbee. Always ready for an adventure, he's just as happy hiking in the mountains as he is cuddling on the couch after a long day of play.&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;More details &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-23"&gt;in the release notes&lt;/a&gt; and &lt;a href="https://llm.datasette.io/en/stable/schemas.html#schemas-tutorial"&gt;LLM schemas tutorial&lt;/a&gt;, which includes an example (extracting people from news articles) that's even more useful than inventing dogs!&lt;/p&gt;



&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/#structured-data-extraction-is-a-killer-app-for-llms"&gt;Structured data extraction is a killer app for LLMs&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/#designing-this-feature-for-llm"&gt;Designing this feature for LLM&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/#reusing-schemas-and-creating-templates"&gt;Reusing schemas and creating templates&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/#doing-more-with-the-logged-structured-data"&gt;Doing more with the logged structured data&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/#using-schemas-from-llm-s-python-library"&gt;Using schemas from LLM's Python library&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/28/llm-schemas/#what-s-next-for-llm-schemas-"&gt;What's next for LLM schemas?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="structured-data-extraction-is-a-killer-app-for-llms"&gt;Structured data extraction is a killer app for LLMs&lt;/h4&gt;
&lt;p&gt;I've suspected for a while that the single most commercially valuable application of LLMs is turning unstructured content into structured data. That's the trick where you feed an LLM an article, or a PDF, or a screenshot and use it to turn that into JSON or CSV or some other structured format.&lt;/p&gt;
&lt;p&gt;It's possible to achieve strong results on this with prompting alone: feed data into an LLM, give it an example of the output you would like and let it figure out the details.&lt;/p&gt;
&lt;p&gt;Many of the leading LLM providers now bake this in as a feature. OpenAI, Anthropic, Gemini and Mistral all offer variants of "structured output" as additional options through their API:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;OpenAI: &lt;a href="https://platform.openai.com/docs/guides/structured-outputs"&gt;Structured Outputs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gemini: &lt;a href="https://ai.google.dev/gemini-api/docs/structured-output?lang=rest"&gt;Generate structured output with the Gemini API&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Mistral: &lt;a href="https://docs.mistral.ai/capabilities/structured-output/custom_structured_output/"&gt;Custom Structured Outputs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Anthropic's &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview"&gt;tool use&lt;/a&gt; can be used for this, as shown in their &lt;a href="https://github.com/anthropics/anthropic-cookbook/blob/main/tool_use/extracting_structured_json.ipynb"&gt;Extracting Structured JSON using Claude and Tool Use&lt;/a&gt; cookbook example.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These mechanisms are all very similar: you pass a &lt;a href="https://json-schema.org/"&gt;JSON schema&lt;/a&gt; to the model defining the shape that you would like, they then use that schema to guide the output of the model.&lt;/p&gt;
&lt;p&gt;How reliable that is can vary! Some providers use tricks along the lines of &lt;a href="https://github.com/1rgs/jsonformer"&gt;Jsonformer&lt;/a&gt;, compiling the JSON schema into code that interacts with the model's next-token generation at runtime, limiting it to only generate tokens that are valid in the context of the schema.&lt;/p&gt;
&lt;p&gt;Other providers YOLO it - they trust that their model is "good enough" that showing it the schema will produce the right results!&lt;/p&gt;
&lt;p&gt;In practice, this means that you need to be aware that sometimes this stuff will go wrong. As with anything LLM, 100% reliability is never guaranteed.&lt;/p&gt;
&lt;p&gt;From my experiments so far, and depending on the model that you chose, these mistakes are rare. If you're using a top tier model it will almost certainly do the right thing.&lt;/p&gt;
&lt;h4 id="designing-this-feature-for-llm"&gt;Designing this feature for LLM&lt;/h4&gt;
&lt;p&gt;I've wanted this feature for ages. I see it as an important step on the way to full tool usage, which is something I'm very excited to bring to the CLI tool and Python library.&lt;/p&gt;
&lt;p&gt;LLM is designed as an abstraction layer over different models. This makes building new features &lt;em&gt;much harder&lt;/em&gt;, because I need to figure out a common denominator and then build an abstraction that captures as much value as possible while still being general enough to work across multiple models.&lt;/p&gt;
&lt;p&gt;Support for structured output across multiple vendors has matured now to the point that I'm ready to commit to a design.&lt;/p&gt;
&lt;p&gt;My first version of this feature worked exclusively with JSON schemas. An earlier version of the tutorial started with this example:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl https://www.nytimes.com/ &lt;span class="pl-k"&gt;|&lt;/span&gt; uvx strip-tags &lt;span class="pl-k"&gt;|&lt;/span&gt; \
  llm --schema &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{&lt;/span&gt;
&lt;span class="pl-s"&gt;  "type": "object",&lt;/span&gt;
&lt;span class="pl-s"&gt;  "properties": {&lt;/span&gt;
&lt;span class="pl-s"&gt;    "items": {&lt;/span&gt;
&lt;span class="pl-s"&gt;      "type": "array",&lt;/span&gt;
&lt;span class="pl-s"&gt;      "items": {&lt;/span&gt;
&lt;span class="pl-s"&gt;        "type": "object",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "properties": {&lt;/span&gt;
&lt;span class="pl-s"&gt;          "headline": {&lt;/span&gt;
&lt;span class="pl-s"&gt;            "type": "string"&lt;/span&gt;
&lt;span class="pl-s"&gt;          },&lt;/span&gt;
&lt;span class="pl-s"&gt;          "short_summary": {&lt;/span&gt;
&lt;span class="pl-s"&gt;            "type": "string"&lt;/span&gt;
&lt;span class="pl-s"&gt;          },&lt;/span&gt;
&lt;span class="pl-s"&gt;          "key_points": {&lt;/span&gt;
&lt;span class="pl-s"&gt;            "type": "array",&lt;/span&gt;
&lt;span class="pl-s"&gt;            "items": {&lt;/span&gt;
&lt;span class="pl-s"&gt;              "type": "string"&lt;/span&gt;
&lt;span class="pl-s"&gt;            }&lt;/span&gt;
&lt;span class="pl-s"&gt;          }&lt;/span&gt;
&lt;span class="pl-s"&gt;        },&lt;/span&gt;
&lt;span class="pl-s"&gt;        "required": ["headline", "short_summary", "key_points"]&lt;/span&gt;
&lt;span class="pl-s"&gt;      }&lt;/span&gt;
&lt;span class="pl-s"&gt;    }&lt;/span&gt;
&lt;span class="pl-s"&gt;  },&lt;/span&gt;
&lt;span class="pl-s"&gt;  "required": ["items"]&lt;/span&gt;
&lt;span class="pl-s"&gt;}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;|&lt;/span&gt; jq&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here we're feeding a full JSON schema document to the new &lt;code&gt;llm --schema&lt;/code&gt; option, then piping in the homepage of the New York Times (after running it through &lt;a href="https://github.com/simonw/strip-tags"&gt;strip-tags&lt;/a&gt;) and asking for &lt;code&gt;headline&lt;/code&gt;, &lt;code&gt;short_summary&lt;/code&gt; and &lt;code&gt;key_points&lt;/code&gt; for multiple items on the page.&lt;/p&gt;
&lt;p&gt;This example still works with the finished feature - you can see &lt;a href="https://gist.github.com/simonw/372d11e2729a9745654740ff3f5669ab"&gt;example JSON output here&lt;/a&gt; - but constructing those long-form schemas by hand was a big pain.&lt;/p&gt;
&lt;p&gt;So... I invented my own shortcut syntax.&lt;/p&gt;
&lt;p&gt;That earlier example is a simple illustration:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm --schema &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;name,age int,short_bio&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;invent a cool dog&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Here the schema is a comma-separated list of field names, with an optional space-separated type.&lt;/p&gt;
&lt;p&gt;The full concise schema syntax &lt;a href="https://llm.datasette.io/en/stable/schemas.html#concise-llm-schema-syntax"&gt;is described here&lt;/a&gt;. There's a more complex example &lt;a href="https://llm.datasette.io/en/latest/schemas.html#extracting-people-from-a-news-articles"&gt;in the tutorial&lt;/a&gt;, which uses the newline-delimited form to extract information about people who are mentioned in a news article:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://apnews.com/article/trump-federal-employees-firings-a85d1aaf1088e050d39dcf7e3664bb9f&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;|&lt;/span&gt; \
  uvx strip-tags &lt;span class="pl-k"&gt;|&lt;/span&gt; \
  llm --schema-multi &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;name: the person's name&lt;/span&gt;
&lt;span class="pl-s"&gt;organization: who they represent&lt;/span&gt;
&lt;span class="pl-s"&gt;role: their job title or role&lt;/span&gt;
&lt;span class="pl-s"&gt;learned: what we learned about them from this story&lt;/span&gt;
&lt;span class="pl-s"&gt;article_headline: the headline of the story&lt;/span&gt;
&lt;span class="pl-s"&gt;article_date: the publication date in YYYY-MM-DD&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; --system &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;extract people mentioned in this article&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;--schema-multi&lt;/code&gt; option here tells LLM to take that schema for a single object and upgrade it to an array of those objects (actually an object with a single &lt;code&gt;"items"&lt;/code&gt; property that's an array of objects), which is a quick way to request that the same schema be returned multiple times against a single input.&lt;/p&gt;
&lt;h4 id="reusing-schemas-and-creating-templates"&gt;Reusing schemas and creating templates&lt;/h4&gt;
&lt;p&gt;My original plan with schemas was to provide a separate &lt;code&gt;llm extract&lt;/code&gt; command for running these kinds of operations. I ended up going in a different direction - I realized that adding &lt;code&gt;--schema&lt;/code&gt; to the default &lt;code&gt;llm prompt&lt;/code&gt; command would make it interoperable with other existing features (like &lt;a href="https://llm.datasette.io/en/stable/usage.html#attachments"&gt;attachments&lt;/a&gt; for feeding in images and PDFs).&lt;/p&gt;
&lt;p&gt;The most valuable way to apply schemas is across many different prompts, in order to gather the same structure of information from many different sources.&lt;/p&gt;
&lt;p&gt;I put a bunch of thought into the &lt;code&gt;--schema&lt;/code&gt; option. It takes a variety of different values - quoting &lt;a href="https://llm.datasette.io/en/latest/schemas.html#ways-to-specify-a-schema"&gt;the documentation&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;This option can take multiple forms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A string providing a JSON schema: &lt;code&gt;--schema '{"type": "object", ...}'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://llm.datasette.io/en/stable/schemas.html#schemas-dsl"&gt;condensed schema definition&lt;/a&gt;: &lt;code&gt;--schema 'name,age int'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The name or path of a file on disk containing a JSON schema: &lt;code&gt;--schema dogs.schema.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The hexadecimal ID of a previously logged schema: &lt;code&gt;--schema 520f7aabb121afd14d0c6c237b39ba2d&lt;/code&gt; - these IDs can be found using the &lt;code&gt;llm schemas&lt;/code&gt; command.&lt;/li&gt;
&lt;li&gt;A schema that has been &lt;a href="https://llm.datasette.io/en/latest/templates.html#prompt-templates-save"&gt;saved in a template&lt;/a&gt;: &lt;code&gt;--schema t:name-of-template&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;a href="https://llm.datasette.io/en/latest/schemas.html#extracting-people-from-a-news-articles"&gt;tutorial&lt;/a&gt; demonstrates saving a schema by using it once and then obtaining its ID through the new &lt;code&gt;llm schemas&lt;/code&gt; command, then saving it to a &lt;a href="https://llm.datasette.io/en/stable/templates.html"&gt;template&lt;/a&gt; (along with the system prompt) like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm --schema 3b7702e71da3dd791d9e17b76c88730e \
  --system &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;extract people mentioned in this article&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; \
  --save people&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And now we can feed in new articles using the &lt;code&gt;llm -t people&lt;/code&gt; shortcut to apply that newly saved template:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl https://www.theguardian.com/commentisfree/2025/feb/27/billy-mcfarland-new-fyre-festival-fantasist &lt;span class="pl-k"&gt;|&lt;/span&gt; \
  strip-tags &lt;span class="pl-k"&gt;|&lt;/span&gt; llm -t people&lt;/pre&gt;&lt;/div&gt;
&lt;h4 id="doing-more-with-the-logged-structured-data"&gt;Doing more with the logged structured data&lt;/h4&gt;
&lt;p&gt;Having run a few prompts that use the same schema, an obvious next step is to do something with the data that has been collected.&lt;/p&gt;
&lt;p&gt;I ended up implementing this on top of the existing &lt;a href="https://llm.datasette.io/en/stable/logging.html"&gt;llm logs&lt;/a&gt; mechanism.&lt;/p&gt;
&lt;p&gt;LLM already defaults to logging every prompt and response it makes to a SQLite database - mine contains over 4,747 of these records now, according to this query:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;sqlite3 &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;llm logs path&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;select count(*) from responses&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;With schemas, an increasing portion of those are valid JSON.&lt;/p&gt;
&lt;p&gt;Since LLM records the schema that was used for each response - using the schema ID, which is derived from a content hash of the expanded JSON schema - it's now possible to ask LLM for all responses that used a particular schema:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm logs --schema 3b7702e71da3dd791d9e17b76c88730e --short&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I got back:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gpt-4o-mini&lt;/span&gt;
  &lt;span class="pl-ent"&gt;datetime&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;2025-02-28T07:37:18&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;conversation&lt;/span&gt;: &lt;span class="pl-s"&gt;01jn5qt397aaxskf1vjp6zxw2a&lt;/span&gt;
  &lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;extract people mentioned in this article&lt;/span&gt;
  &lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;Menu AP Logo Menu World U.S. Politics Sports Entertainment Business Science&lt;/span&gt;
    &lt;span class="pl-s"&gt;Fact Check Oddities Be Well Newsletters N...&lt;/span&gt;
- &lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gpt-4o-mini&lt;/span&gt;
  &lt;span class="pl-ent"&gt;datetime&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;2025-02-28T07:38:58&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;conversation&lt;/span&gt;: &lt;span class="pl-s"&gt;01jn5qx4q5he7yq803rnexp28p&lt;/span&gt;
  &lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;extract people mentioned in this article&lt;/span&gt;
  &lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;Skip to main contentSkip to navigationSkip to navigationPrint subscriptionsNewsletters&lt;/span&gt;
    &lt;span class="pl-s"&gt;Sign inUSUS editionUK editionA...&lt;/span&gt;
- &lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gpt-4o&lt;/span&gt;
  &lt;span class="pl-ent"&gt;datetime&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;2025-02-28T07:39:07&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;conversation&lt;/span&gt;: &lt;span class="pl-s"&gt;01jn5qxh20tksb85tf3bx2m3bd&lt;/span&gt;
  &lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;extract people mentioned in this article&lt;/span&gt;
  &lt;span class="pl-ent"&gt;attachments&lt;/span&gt;:
  - &lt;span class="pl-ent"&gt;type&lt;/span&gt;: &lt;span class="pl-s"&gt;image/jpeg&lt;/span&gt;
    &lt;span class="pl-ent"&gt;url&lt;/span&gt;: &lt;span class="pl-s"&gt;https://static.simonwillison.net/static/2025/onion-zuck.jpg&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;As you can see, I've run that example schema three times (while constructing the tutorial) using GPT-4o mini - twice against text content from &lt;code&gt;curl ... | strip-tags&lt;/code&gt; and once against &lt;a href="https://static.simonwillison.net/static/2025/onion-zuck.jpg"&gt;a screenshot JPEG&lt;/a&gt; to demonstrate attachment support.&lt;/p&gt;
&lt;p&gt;Extracting gathered JSON from the logs is clearly a useful next step... so I added several options to &lt;code&gt;llm logs&lt;/code&gt; to support that use-case.&lt;/p&gt;
&lt;p&gt;The first is &lt;code&gt;--data&lt;/code&gt; - adding that will cause &lt;code&gt;LLM logs&lt;/code&gt; to output just the data that was gathered using a schema. Mix that with &lt;code&gt;-c&lt;/code&gt; to see the JSON from the most recent response:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm logs -c --data&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Outputs:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&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;Zap&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-ent"&gt;"age"&lt;/span&gt;: &lt;span class="pl-c1"&gt;5&lt;/span&gt;, &lt;span class="pl-ent"&gt;"short_bio"&lt;/span&gt;: ...&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Combining that with the &lt;code&gt;--schema&lt;/code&gt; option is where things get really interesting. You can specify a schema using any of the mechanisms described earlier, which means you can see ALL of the data gathered using that schema by combining &lt;code&gt;--data&lt;/code&gt; with &lt;code&gt;--schema X&lt;/code&gt; (and &lt;code&gt;-n 0&lt;/code&gt; for everything).&lt;/p&gt;
&lt;p&gt;Here are all of the dogs I've invented:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm logs --schema &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;name,age int,short_bio&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; --data -n 0&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Output (here truncated):&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&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;Zap&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-ent"&gt;"age"&lt;/span&gt;: &lt;span class="pl-c1"&gt;5&lt;/span&gt;, &lt;span class="pl-ent"&gt;"short_bio"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Zap is a futuristic ...&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;Zephyr&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-ent"&gt;"age"&lt;/span&gt;: &lt;span class="pl-c1"&gt;3&lt;/span&gt;, &lt;span class="pl-ent"&gt;"short_bio"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Zephyr is an adventurous...&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;Zylo&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-ent"&gt;"age"&lt;/span&gt;: &lt;span class="pl-c1"&gt;4&lt;/span&gt;, &lt;span class="pl-ent"&gt;"short_bio"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Zylo is a unique ...&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Some schemas gather multiple items, producing output that looks like this (from the tutorial):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{"items": [{"name": "Mark Zuckerberg", "organization": "...
{"items": [{"name": "Billy McFarland", "organization": "...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can get back the individual objects by adding &lt;code&gt;--data-key items&lt;/code&gt;. Here I'm also using the &lt;code&gt;--schema t:people&lt;/code&gt; shortcut to specify the schema that was saved to the &lt;code&gt;people&lt;/code&gt; template earlier on.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm logs --schema t:people --data-key items&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{"name": "Katy Perry", "organization": ...
{"name": "Gayle King", "organization": ...
{"name": "Lauren Sanchez", "organization": ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This feature defaults to outputting newline-delimited JSON, but you can add the &lt;code&gt;--data-array&lt;/code&gt; flag to get back a JSON array of objects instead.&lt;/p&gt;
&lt;p&gt;... which means you can pipe it into &lt;a href="https://sqlite-utils.datasette.io/en/stable/cli.html#inserting-json-data"&gt;sqlite-utils insert&lt;/a&gt; to create a SQLite database!&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm logs --schema t:people --data-key items --data-array &lt;span class="pl-k"&gt;|&lt;/span&gt; \
  sqlite-utils insert data.db people -&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Add all of this together and we can construct a schema, run it against a bunch of sources and dump the resulting structured data into SQLite where we can explore it using SQL queries (and &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;). It's a really powerful combination.&lt;/p&gt;
&lt;h4 id="using-schemas-from-llm-s-python-library"&gt;Using schemas from LLM's Python library&lt;/h4&gt;
&lt;p&gt;The most popular way to work with schemas in Python these days is with &lt;a href="https://docs.pydantic.dev/"&gt;Pydantic&lt;/a&gt;, to the point that many of the official API libraries for models directly incorporate Pydantic for this purpose.&lt;/p&gt;
&lt;p&gt;LLM depended on Pydantic already, and for this project I finally dropped my dual support for Pydantic v1 and v2 and &lt;a href="https://github.com/simonw/llm/pull/775"&gt;committed to v2 only&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A key reason Pydantic is popular for this is that it's trivial to use it to build a JSON schema document:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;pydantic&lt;/span&gt;, &lt;span class="pl-s1"&gt;json&lt;/span&gt;

&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;Dog&lt;/span&gt;(&lt;span class="pl-s1"&gt;pydantic&lt;/span&gt;.&lt;span class="pl-c1"&gt;BaseModel&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;name&lt;/span&gt;: &lt;span class="pl-smi"&gt;str&lt;/span&gt;
    &lt;span class="pl-s1"&gt;age&lt;/span&gt;: &lt;span class="pl-smi"&gt;int&lt;/span&gt;
    &lt;span class="pl-s1"&gt;bio&lt;/span&gt;: &lt;span class="pl-smi"&gt;str&lt;/span&gt;

&lt;span class="pl-s1"&gt;schema&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Dog&lt;/span&gt;.&lt;span class="pl-c1"&gt;model_json_schema&lt;/span&gt;()
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;json&lt;/span&gt;.&lt;span class="pl-c1"&gt;dumps&lt;/span&gt;(&lt;span class="pl-s1"&gt;schema&lt;/span&gt;, &lt;span class="pl-s1"&gt;indent&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;2&lt;/span&gt;))&lt;/pre&gt;
&lt;p&gt;Outputs:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"properties"&lt;/span&gt;: {
    &lt;span class="pl-ent"&gt;"name"&lt;/span&gt;: {
      &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"type"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;string&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    },
    &lt;span class="pl-ent"&gt;"age"&lt;/span&gt;: {
      &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Age&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"type"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;integer&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    },
    &lt;span class="pl-ent"&gt;"bio"&lt;/span&gt;: {
      &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Bio&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"type"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;string&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    }
  },
  &lt;span class="pl-ent"&gt;"required"&lt;/span&gt;: [
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;name&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;age&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;bio&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
  ],
  &lt;span class="pl-ent"&gt;"title"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Dog&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"type"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;object&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;LLM's Python library doesn't require you to use Pydantic, but it supports passing either a Pydantic &lt;code&gt;BaseModel&lt;/code&gt; subclass or a full JSON schema to the new &lt;code&gt;model.prompt(schema=)&lt;/code&gt; parameter. Here's &lt;a href="https://llm.datasette.io/en/latest/python-api.html#schemas"&gt;the usage example&lt;/a&gt; from the documentation:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;, &lt;span class="pl-s1"&gt;json&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;pydantic&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;BaseModel&lt;/span&gt;

&lt;span class="pl-k"&gt;class&lt;/span&gt; &lt;span class="pl-v"&gt;Dog&lt;/span&gt;(&lt;span class="pl-v"&gt;BaseModel&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;name&lt;/span&gt;: &lt;span class="pl-smi"&gt;str&lt;/span&gt;
    &lt;span class="pl-s1"&gt;age&lt;/span&gt;: &lt;span class="pl-smi"&gt;int&lt;/span&gt;

&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-4o-mini"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Describe a nice dog"&lt;/span&gt;, &lt;span class="pl-s1"&gt;schema&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-v"&gt;Dog&lt;/span&gt;)
&lt;span class="pl-s1"&gt;dog&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;json&lt;/span&gt;.&lt;span class="pl-c1"&gt;loads&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;dog&lt;/span&gt;)
&lt;span class="pl-c"&gt;# {"name":"Buddy","age":3}&lt;/span&gt;&lt;/pre&gt;
&lt;h4 id="what-s-next-for-llm-schemas-"&gt;What's next for LLM schemas?&lt;/h4&gt;
&lt;p&gt;So far I've implemented schema support for models from OpenAI, Anthropic and Gemini. The &lt;a href="https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#supporting-schemas"&gt;plugin author documentation&lt;/a&gt; includes details on how to add this to further plugins - I'd love to see one of the local model plugins implement this pattern as well.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt; &lt;a href="https://github.com/taketwo/llm-ollama"&gt;llm-ollama&lt;/a&gt; now support schemas thanks to &lt;a href="https://github.com/taketwo/llm-ollama/pull/36"&gt;this PR&lt;/a&gt; by Adam Compton. And I've added support &lt;a href="https://simonwillison.net/2025/Mar/4/llm-mistral-011/"&gt;to llm-mistral&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I'm presenting a workshop at the &lt;a href="https://www.ire.org/training/conferences/nicar-2025/"&gt;NICAR 2025&lt;/a&gt; data journalism conference next week about &lt;a href="https://github.com/simonw/nicar-2025-scraping/"&gt;Cutting-edge web scraping techniques&lt;/a&gt;. LLM schemas is a great example of NDD - NICAR-Driven Development - where I'm churning out features I need for that conference (see also shot-scraper's new &lt;a href="https://shot-scraper.datasette.io/en/stable/har.html"&gt;HAR support&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;I expect the workshop will be a great opportunity to further refine the design and implementation of this feature!&lt;/p&gt;
&lt;p&gt;I'm also going to be using this new feature to add multiple model support to my &lt;a href="https://www.datasette.cloud/blog/2024/datasette-extract/"&gt;datasette-extract plugin&lt;/a&gt;, which provides a web UI for structured data extraction that writes the resulting records directly to a SQLite database table.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/cli"&gt;cli&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/data-journalism"&gt;data-journalism&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/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/local-llms"&gt;local-llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm"&gt;llm&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mistral"&gt;mistral&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gemini"&gt;gemini&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ollama"&gt;ollama&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/structured-extraction"&gt;structured-extraction&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="cli"/><category term="data-journalism"/><category term="projects"/><category term="ai"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="local-llms"/><category term="llms"/><category term="llm"/><category term="mistral"/><category term="gemini"/><category term="ollama"/><category term="structured-extraction"/></entry><entry><title>Claude 3.7 Sonnet, extended thinking and long output, llm-anthropic 0.14</title><link href="https://simonwillison.net/2025/Feb/25/llm-anthropic-014/#atom-tag" rel="alternate"/><published>2025-02-25T17:50:25+00:00</published><updated>2025-02-25T17:50:25+00:00</updated><id>https://simonwillison.net/2025/Feb/25/llm-anthropic-014/#atom-tag</id><summary type="html">
    &lt;p&gt;Claude 3.7 Sonnet (&lt;a href="https://simonwillison.net/2025/Feb/24/claude-37-sonnet-and-claude-code/"&gt;previously&lt;/a&gt;) is a &lt;em&gt;very&lt;/em&gt; interesting new model. I released &lt;a href="https://github.com/simonw/llm-anthropic/releases/tag/0.14"&gt;llm-anthropic 0.14&lt;/a&gt; last night adding support for the new model's features to &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt;. I learned a whole lot about the new model in the process of building that plugin.&lt;/p&gt;
&lt;p&gt;The base Claude 3.7 Sonnet model mostly feels like an incremental improvement over the excellent Claude 3.5 Sonnet. It benchmarks better and my vibe checks so far are good: it writes excellent code across Python and JavaScript and seems to do everything 3.5 did, for the same price but with an uptick in overall quality.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/25/llm-anthropic-014/#extended-thinking-mode"&gt;Extended thinking mode&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/25/llm-anthropic-014/#running-thinking-prompts"&gt;Running thinking prompts&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/25/llm-anthropic-014/#llm-anthropic-doesn-t-support-visible-thinking-tokens-yet"&gt;llm-anthropic doesn't support visible thinking tokens yet&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/25/llm-anthropic-014/#a-massively-increased-output-limit"&gt;A massively increased output limit&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/25/llm-anthropic-014/#everything-else-in-the-plugin-release"&gt;Everything else in the plugin release&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="extended-thinking-mode"&gt;Extended thinking mode&lt;/h4&gt;
&lt;p&gt;The &lt;strong&gt;big&lt;/strong&gt; new feature is &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking"&gt;extended thinking&lt;/a&gt; mode. This is the option that turns Claude into an &lt;a href="https://simonwillison.net/tags/inference-scaling/"&gt;inference scaling&lt;/a&gt; model similar to OpenAI's o1 and o3, Gemini 2.0 Flash Thinking and DeepSeek R1.&lt;/p&gt;
&lt;p&gt;From my plugin's release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Support for the new &lt;strong&gt;Claude 3.7 Sonnet&lt;/strong&gt; model, including &lt;code&gt;-o thinking 1&lt;/code&gt; to turn on &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking"&gt;extended thinking&lt;/a&gt; mode and &lt;code&gt;-o thinking_budget X&lt;/code&gt; to increase the thinking token budget from the 1024 default. &lt;a href="https://github.com/simonw/llm-anthropic/issues/14"&gt;#14&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;You need to opt-in to this new mode. In the Claude API that means appending this block of JSON to your request:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
    &lt;span class="pl-ent"&gt;"thinking"&lt;/span&gt;: {
        &lt;span class="pl-ent"&gt;"type"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;enabled&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
        &lt;span class="pl-ent"&gt;"budget_tokens"&lt;/span&gt;: &lt;span class="pl-c1"&gt;1024&lt;/span&gt;
    }
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;budget_tokens&lt;/code&gt; defines how many tokens Claude can spend "thinking" about your prompt. 1,024 is the minimum, but you can increase that up to a maximum of 128,000 (see below).&lt;/p&gt;
&lt;p&gt;This is Claude's equivalent of OpenAI's &lt;a href="https://platform.openai.com/docs/guides/reasoning#reasoning-effort"&gt;reasoning_effort&lt;/a&gt; option, which can be set to low, medium (the default) or high.&lt;/p&gt;
&lt;h4 id="running-thinking-prompts"&gt;Running thinking prompts&lt;/h4&gt;
&lt;p&gt;You can run a "thinking" prompt through LLM like this - the new &lt;code&gt;-o thinking 1&lt;/code&gt; option switches on the new extended thinking mode:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm install -U llm-anthropic
llm -m claude-3.7-sonnet -o thinking 1 &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;write a speech about pelicans for congress&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;-o thinking_budget 4000&lt;/code&gt; option can increase the number of allowed thinking tokens from the default value of 1024.&lt;/p&gt;
&lt;p&gt;For the moment the thinking tokens are not visible - but you can review them once the prompt has completed using the &lt;code&gt;llm logs --json&lt;/code&gt; command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm logs --json -c&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;-c&lt;/code&gt; option is inherited from "continue previous conversation" - it will show the log for the most recently completed prompt.&lt;/p&gt;
&lt;p&gt;You can see &lt;a href="https://gist.github.com/simonw/116e3c0cc7dba5446dbd587c6fc6147d"&gt;the speech about pelicans&lt;/a&gt; here, but here's the more interesting thought process that was output by the model:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I'm being asked to write a speech about pelicans that would be delivered to Congress. This seems like an unusual request, but I can craft a formal, informative speech that might be given by someone advocating for pelican conservation, environmental protection, or perhaps using pelicans as a symbol for some broader policy discussion.&lt;/p&gt;
&lt;p&gt;The speech should:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Be formal and appropriate for Congress&lt;/li&gt;
&lt;li&gt;Contain factual information about pelicans&lt;/li&gt;
&lt;li&gt;Have a clear purpose (likely conservation-related)&lt;/li&gt;
&lt;li&gt;Include rhetorical elements typical of congressional speeches&lt;/li&gt;
&lt;li&gt;Maintain a respectful, bipartisan tone&lt;/li&gt;
&lt;li&gt;Perhaps connect pelicans to broader environmental policies or American values&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I'll write a speech that could plausibly be given by a member of Congress, a cabinet secretary (like the Secretary of the Interior), or an expert testifying before a congressional committee about environmental matters.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Unlike OpenAI, Anthropic chose not to obscure the thinking tokens - what you see is what the model used. There's one interesting edge-case described in their detailed documentation &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#understanding-thinking-blocks"&gt;about the extended thinking API output&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Occasionally Claude's internal reasoning will be flagged by our safety systems. When this occurs, we encrypt some or all of the &lt;code&gt;thinking&lt;/code&gt; block and return it to you as a &lt;code&gt;redacted_thinking&lt;/code&gt; block. These redacted thinking blocks are decrypted when passed back to the API, allowing Claude to continue its response without losing context.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id="llm-anthropic-doesn-t-support-visible-thinking-tokens-yet"&gt;llm-anthropic doesn't support visible thinking tokens yet&lt;/h4&gt;
&lt;p&gt;I had a couple of attempts at implementing this. My &lt;a href="https://github.com/simonw/llm-anthropic/pull/15"&gt;first try&lt;/a&gt; included options to make the thinking tokens visible as the tool was running. This turned out to involve unexpected challenges: the rest of LLM doesn't yet understand that some tokens should be treated differently, and I quickly ran into challenges concerning how those responses were logged to the database.&lt;/p&gt;
&lt;p&gt;In the interests of getting support for the new model out I simplified my approach. I plan to add visible thinking tokens &lt;a href="https://github.com/simonw/llm/issues/770"&gt;in a future LLM release&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This is part of a larger challenge for LLM described in this issue: &lt;a href="https://github.com/simonw/llm/issues/716"&gt;Design annotations abstraction for responses that are not just a stream of plain text&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="a-massively-increased-output-limit"&gt;A massively increased output limit&lt;/h4&gt;
&lt;p&gt;A fascinating new capability of Claude 3.7 Sonnet is that its output limit in extended thinking mode can be extended to an extraordinary &lt;strong&gt;128,000 tokens&lt;/strong&gt; - 15x more than the previous Claude output limit of 8,192 tokens.&lt;/p&gt;
&lt;p&gt;(This is the &lt;strong&gt;output limit&lt;/strong&gt; - how much text it can produce in one go. Claude 3.7 Sonnet's input limit remains 200,000 - many modern models exceed 100,000 for input now.)&lt;/p&gt;
&lt;p&gt;I added support for that to the plugin as well - if you pass &lt;code&gt;-o max_output 128000&lt;/code&gt; it automatically calls Anthropic's beta API with the &lt;code&gt;output-128k-2025-02-19&lt;/code&gt; beta header, &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#extended-output-capabilities-beta"&gt;documented here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Testing this was pretty hard! I eventually &lt;a href="https://github.com/simonw/llm-anthropic/pull/18#issuecomment-2680946698"&gt;found a prompt&lt;/a&gt; that exercised this fully:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm -m claude-3.7-sonnet \
  -o max_tokens 128000 \
  -o thinking_budget 32000 \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;For every one of the 100 US senators that you know of output their name, biography and a note about how to strategically convince them to take more interest in the plight of the California Brown Pelican, then a poem about them, then that same poem translated to Spanish and then to Japanese. Do not miss any senators.&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; \
  -s &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;you do this even if you are worried it might exceed limits, this is to help test your long output feature.&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This is an expensive command to run - the resulting prompt cost me $1.72 and took nearly 27 minutes to finish returning the answer! You can see &lt;a href="https://gist.github.com/simonw/854474b050b630144beebf06ec4a2f52"&gt;the full 114,584 token output here&lt;/a&gt; - it managed to output results for all 100 senators as of its training cut-off date, correctly following my instructions for each one.&lt;/p&gt;
&lt;p&gt;This is one of the few cases where I've felt guilty about &lt;a href="https://simonwillison.net/2024/Dec/31/llms-in-2024/#the-environmental-impact-got-better"&gt;the energy usage of my prompts&lt;/a&gt; - burning GPUs for 27 minutes feels like it's a genuinely significant use of resources!&lt;/p&gt;
&lt;p&gt;Anthropic recommend &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/extended-thinking-tips#technical-considerations-for-extended-thinking"&gt;using batch mode&lt;/a&gt; for long prompts like this, which comes with a 50% discount presumably as it runs on GPUs that happen to have spare capacity. This can be quite performant: &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/batch-processing#batch-limitations"&gt;Anthropic say&lt;/a&gt; "We process each batch as fast as possible, with most batches completing within 1 hour".&lt;/p&gt;
&lt;p&gt;I find the end result of my long test prompt to be &lt;em&gt;very&lt;/em&gt; impressive. A common complaint I've seen about LLMs is that they tend to fail when you ask them to perform comprehensive tasks like this - things where they need to curate information across a larger number of distinct items. This feels intuitively like the kind of thing a computer should be good at but most models make mistakes or hallucinate when challenged in this way.&lt;/p&gt;
&lt;p&gt;Claude 3.7 Sonnet really did manage to output correct text across all 100 US senators, albeit limited by the new model's October/Nov 2024 &lt;a href="https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table"&gt;training cut-off date&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The extended output limit is notable as well. Most models are still limited to 4,000 or 8,000 characters - 120,000 is a huge leap forward.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Update:&lt;/strong&gt; I was wrong about that: OpenAI's &lt;a href="https://platform.openai.com/docs/models#o1"&gt;o1 and o3-mini&lt;/a&gt; both have a 100,000 output token limit now.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I mentioned that my prompt took 27 minutes to run. On further inspection of my logs it looks like this is expected - Claude 3.7 Sonnet appears to take around 14ms per output token, and 14 * 114584 = 1604176 ms which is 1604 seconds or around 26.7 minutes. This is a downside of longer outputs I had not previously considered!&lt;/p&gt;
&lt;h4 id="everything-else-in-the-plugin-release"&gt;Everything else in the plugin release&lt;/h4&gt;
&lt;p&gt;There are a few smaller changes in the new plugin release:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Claude 3.5 Haiku now supports image inputs. &lt;a href="https://github.com/simonw/llm-anthropic/issues/17"&gt;#17&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is tucked away in Anthropic's &lt;a href="https://docs.anthropic.com/en/release-notes/api#february-24th-2025"&gt;February 24th 2025 release notes&lt;/a&gt;. Previously their less expensive 3.5 Haiku model couldn't handle images - the only modern Claude model without that ability. They've fixed that now.&lt;/p&gt;
&lt;p&gt;The rest of the changes in the 0.14 release are bug fixes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Fixed a bug that occurred when continuing an existing conversation using &lt;code&gt;--async&lt;/code&gt; mode. &lt;a href="https://github.com/simonw/llm-anthropic/issues/13"&gt;#13&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fixed a bug where &lt;code&gt;max_tokens&lt;/code&gt; and &lt;code&gt;temperature&lt;/code&gt; were logged in the database even when using their default options. &lt;a href="https://github.com/simonw/llm-anthropic/issues/16"&gt;#16&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/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/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/llm-reasoning"&gt;llm-reasoning&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-release"&gt;llm-release&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="ai"/><category term="annotated-release-notes"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="anthropic"/><category term="claude"/><category term="llm-reasoning"/><category term="llm-release"/></entry><entry><title>files-to-prompt 0.6</title><link href="https://simonwillison.net/2025/Feb/19/files-to-prompt/#atom-tag" rel="alternate"/><published>2025-02-19T06:12:12+00:00</published><updated>2025-02-19T06:12:12+00:00</updated><id>https://simonwillison.net/2025/Feb/19/files-to-prompt/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/files-to-prompt/releases/tag/0.6"&gt;files-to-prompt 0.6&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New release of my CLI tool for turning a whole directory of code into a single prompt ready to pipe or paste into an LLM.&lt;/p&gt;
&lt;p&gt;Here are the full release notes:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;ul&gt;&lt;li&gt;New &lt;code&gt;-m/--markdown&lt;/code&gt; option for outputting results as Markdown with each file in a fenced code block. &lt;a href="https://github.com/simonw/files-to-prompt/issues/42"&gt;#42&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Support for reading a list of files from standard input. Thanks, &lt;a href="https://github.com/thelastnode"&gt;Ankit Shankar&lt;/a&gt;. &lt;a href="https://github.com/simonw/files-to-prompt/issues/44"&gt;#44&lt;/a&gt;&lt;br&gt;
  Here's how to process just files modified within the last day:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;find . -mtime -1 | files-to-prompt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also use the &lt;code&gt;-0/--null&lt;/code&gt; flag to accept lists of file paths separated by null delimiters, which is useful for handling file names with spaces in them:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;find . -name "*.txt" -print0 | files-to-prompt -0
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p&gt;I also have a potential fix for a reported bug concerning nested &lt;code&gt;.gitignore&lt;/code&gt; files that's currently &lt;a href="https://github.com/simonw/files-to-prompt/pull/45"&gt;sitting in a PR&lt;/a&gt;. I'm waiting for someone else to confirm that it behaves as they would expect. I've left &lt;a href="https://github.com/simonw/files-to-prompt/issues/40#issuecomment-2667571418"&gt;details in this issue comment&lt;/a&gt;, but the short version is that you can try out the version from the PR using this &lt;code&gt;uvx&lt;/code&gt; incantation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx --with git+https://github.com/simonw/files-to-prompt@nested-gitignore files-to-prompt
&lt;/code&gt;&lt;/pre&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/cli"&gt;cli&lt;/a&gt;, &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/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/files-to-prompt"&gt;files-to-prompt&lt;/a&gt;&lt;/p&gt;



</summary><category term="cli"/><category term="projects"/><category term="annotated-release-notes"/><category term="llms"/><category term="uv"/><category term="files-to-prompt"/></entry><entry><title>LLM 0.22, the annotated release notes</title><link href="https://simonwillison.net/2025/Feb/17/llm/#atom-tag" rel="alternate"/><published>2025-02-17T06:19:00+00:00</published><updated>2025-02-17T06:19:00+00:00</updated><id>https://simonwillison.net/2025/Feb/17/llm/#atom-tag</id><summary type="html">
    &lt;p&gt;I released &lt;a href="https://llm.datasette.io/en/stable/changelog.html#v0-22"&gt;LLM 0.22&lt;/a&gt; this evening. Here are the &lt;a href="https://simonwillison.net/tags/annotated-release-notes/"&gt;annotated release notes&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="#model-prompt-key-for-api-keys"&gt;model.prompt(..., key=) for API keys&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#chatgpt-4o-latest"&gt;chatgpt-4o-latest&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#llm-logs-s-short"&gt;llm logs -s/--short&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#llm-models-q-gemini-q-exp"&gt;llm models -q gemini -q exp&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#llm-embed-multi-prepend-x"&gt;llm embed-multi --prepend X&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="#everything-else"&gt;Everything else&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id="model-prompt-key-for-api-keys"&gt;model.prompt(..., key=) for API keys&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Plugins that provide models that use API keys can now subclass the new &lt;code&gt;llm.KeyModel&lt;/code&gt; and &lt;code&gt;llm.AsyncKeyModel&lt;/code&gt; classes. This results in the API key being passed as a new &lt;code&gt;key&lt;/code&gt; parameter to their &lt;code&gt;.execute()&lt;/code&gt; methods, and means that Python users can pass a key as the &lt;code&gt;model.prompt(..., key=)&lt;/code&gt; - see &lt;a href="https://llm.datasette.io/en/stable/python-api.html#python-api-models-api-keys"&gt;Passing an API key&lt;/a&gt;. Plugin developers should consult the new documentation on writing &lt;a href="https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#advanced-model-plugins-api-keys"&gt;Models that accept API keys&lt;/a&gt;. &lt;a href="https://github.com/simonw/llm/issues/744"&gt;#744&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is the big change. It's only relevant to you if you use LLM as a Python library &lt;em&gt;and&lt;/em&gt; you need the ability to pass API keys for OpenAI, Anthropic, Gemini etc in yourself in Python code rather than setting them as an environment variable.&lt;/p&gt;
&lt;p&gt;It turns out I need to do that for Datasette Cloud, where API keys are retrieved from individual customer's secret stores!&lt;/p&gt;
&lt;p&gt;Thanks to this change, it's now possible to do things like this - the &lt;code&gt;key=&lt;/code&gt; parameter to &lt;code&gt;model.prompt()&lt;/code&gt; is new:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;
&lt;span class="pl-s1"&gt;model&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;llm&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_model&lt;/span&gt;(&lt;span class="pl-s"&gt;"gpt-4o-mini"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;model&lt;/span&gt;.&lt;span class="pl-c1"&gt;prompt&lt;/span&gt;(&lt;span class="pl-s"&gt;"Surprise me!"&lt;/span&gt;, &lt;span class="pl-s1"&gt;key&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"my-api-key"&lt;/span&gt;)
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;response&lt;/span&gt;.&lt;span class="pl-c1"&gt;text&lt;/span&gt;())&lt;/pre&gt;
&lt;p&gt;Other plugins need to be updated to take advantage of this new feature. Here's &lt;a href="https://llm.datasette.io/en/stable/plugins/advanced-model-plugins.html#models-that-accept-api-keys"&gt;the documentation for plugin developers&lt;/a&gt; - I've released &lt;a href="https://github.com/simonw/llm-anthropic/releases/tag/0.13"&gt;llm-anthropic 0.13&lt;/a&gt; and &lt;a href="https://github.com/simonw/llm-gemini/releases/tag/0.11"&gt;llm-gemini 0.11&lt;/a&gt; implementing the new pattern.&lt;/p&gt;
&lt;h4 id="chatgpt-4o-latest"&gt;chatgpt-4o-latest&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New OpenAI model: &lt;code&gt;chatgpt-4o-latest&lt;/code&gt;. This model ID accesses the current model being used to power ChatGPT, which can change without warning. &lt;a href="https://github.com/simonw/llm/issues/752"&gt;#752&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This model has actually been around since &lt;a href="https://twitter.com/openaidevs/status/1823510395619000525"&gt;August 2024&lt;/a&gt; but I had somehow missed it. &lt;code&gt;chatgpt-4o-latest&lt;/code&gt; is a model alias that provides access to the current model that is being used for GPT-4o running on ChatGPT, which is &lt;em&gt;not&lt;/em&gt; the same as the GPT-4o models usually available via the API. It got &lt;a href="https://twitter.com/edwinarbus/status/1890841371675619728"&gt;an upgrade&lt;/a&gt; last week so it's currently the alias that provides access to the most recently released OpenAI model.&lt;/p&gt;
&lt;p&gt;Most OpenAI models such as &lt;code&gt;gpt-4o&lt;/code&gt; provide stable date-based aliases like &lt;code&gt;gpt-4o-2024-08-06&lt;/code&gt; which effectively let you "pin" to that exact model version. OpenAI technical staff &lt;a href="https://twitter.com/zedlander/status/1890937885848715443"&gt;have confirmed&lt;/a&gt; that they don't change the model without updating that name.&lt;/p&gt;
&lt;p&gt;The one exception is &lt;code&gt;chatgpt-4o-latest&lt;/code&gt; - that one can change without warning and doesn't appear to have release notes at all.&lt;/p&gt;
&lt;p&gt;It's also a little more expensive that &lt;code&gt;gpt-4o&lt;/code&gt; - currently priced at $5/million tokens for input and $15/million for output, compared to GPT 4o's $2.50/$10.&lt;/p&gt;
&lt;p&gt;It's a fun model to play with though! As of last week it appears to be very chatty and keen on &lt;a href="https://github.com/simonw/llm/issues/752#issuecomment-2661184024"&gt;using emoji&lt;/a&gt;. It also claims that it has a July 2024 training cut-off.&lt;/p&gt;
&lt;h4 id="llm-logs-s-short"&gt;llm logs -s/--short&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;llm logs -s/--short&lt;/code&gt; flag, which returns a greatly shortened version of the matching log entries in YAML format with a truncated prompt and without including the response. &lt;a href="https://github.com/simonw/llm/issues/737"&gt;#737&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;code&gt;llm logs&lt;/code&gt; command lets you search through logged prompt-response pairs - I have 4,419 of them in my database, according to this command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;sqlite-utils tables &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;$(&lt;/span&gt;llm logs path&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; --counts  &lt;span class="pl-k"&gt;|&lt;/span&gt; grep responses&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;By default it outputs the full prompts and responses as Markdown - and since I've started leaning more into long context models (&lt;a href="https://simonwillison.net/2025/Feb/14/files-to-prompt/"&gt;some recent examples&lt;/a&gt;) my logs have been getting pretty hard to navigate.&lt;/p&gt;
&lt;p&gt;The new &lt;code&gt;-s/--short&lt;/code&gt; flag provides a much more concise YAML format. Here are some of my recent prompts that I've run using Google's Gemini 2.0 Pro experimental model - the &lt;code&gt;-u&lt;/code&gt; flag includes usage statistics, and &lt;code&gt;-n 4&lt;/code&gt; limits the output to the most recent 4 entries:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm logs --short -m gemini-2.0-pro-exp-02-05 -u -n 4&lt;/pre&gt;&lt;/div&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;- &lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gemini-2.0-pro-exp-02-05&lt;/span&gt;
  &lt;span class="pl-ent"&gt;datetime&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;2025-02-13T22:30:48&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;conversation&lt;/span&gt;: &lt;span class="pl-s"&gt;01jm0q045fqp5xy5pn4j1bfbxs&lt;/span&gt;
  &lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;documents&amp;gt; &amp;lt;document index="1"&amp;gt; &amp;lt;source&amp;gt;./index.md&amp;lt;/source&amp;gt; &amp;lt;document_content&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    # uv An extremely fast Python package...&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;usage&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;input&lt;/span&gt;: &lt;span class="pl-c1"&gt;281812&lt;/span&gt;
    &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-c1"&gt;1521&lt;/span&gt;
- &lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gemini-2.0-pro-exp-02-05&lt;/span&gt;
  &lt;span class="pl-ent"&gt;datetime&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;2025-02-13T22:32:29&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;conversation&lt;/span&gt;: &lt;span class="pl-s"&gt;01jm0q045fqp5xy5pn4j1bfbxs&lt;/span&gt;
  &lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;I want to set it globally so if I run uv run python anywhere on my computer&lt;/span&gt;
    &lt;span class="pl-s"&gt;I always get 3.13&lt;/span&gt;
  &lt;span class="pl-ent"&gt;usage&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;input&lt;/span&gt;: &lt;span class="pl-c1"&gt;283369&lt;/span&gt;
    &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-c1"&gt;1540&lt;/span&gt;
- &lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gemini-2.0-pro-exp-02-05&lt;/span&gt;
  &lt;span class="pl-ent"&gt;datetime&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;2025-02-14T23:23:57&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;conversation&lt;/span&gt;: &lt;span class="pl-s"&gt;01jm3cek8eb4z8tkqhf4trk98b&lt;/span&gt;
  &lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;documents&amp;gt; &amp;lt;document index="1"&amp;gt; &amp;lt;source&amp;gt;./LORA.md&amp;lt;/source&amp;gt; &amp;lt;document_content&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    # Fine-Tuning with LoRA or QLoRA You c...&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;usage&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;input&lt;/span&gt;: &lt;span class="pl-c1"&gt;162885&lt;/span&gt;
    &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-c1"&gt;2558&lt;/span&gt;
- &lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gemini-2.0-pro-exp-02-05&lt;/span&gt;
  &lt;span class="pl-ent"&gt;datetime&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;2025-02-14T23:30:13&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;conversation&lt;/span&gt;: &lt;span class="pl-s"&gt;01jm3csstrfygp35rk0y1w3rfc&lt;/span&gt;
  &lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&amp;lt;documents&amp;gt; &amp;lt;document index="1"&amp;gt; &amp;lt;source&amp;gt;huggingface_hub/__init__.py&amp;lt;/source&amp;gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;    &amp;lt;document_content&amp;gt; # Copyright 2020 The...&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
  &lt;span class="pl-ent"&gt;usage&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;input&lt;/span&gt;: &lt;span class="pl-c1"&gt;480216&lt;/span&gt;
    &lt;span class="pl-ent"&gt;output&lt;/span&gt;: &lt;span class="pl-c1"&gt;1791&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h4 id="llm-models-q-gemini-q-exp"&gt;llm models -q gemini -q exp&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Both &lt;code&gt;llm models&lt;/code&gt; and &lt;code&gt;llm embed-models&lt;/code&gt; now take multiple &lt;code&gt;-q&lt;/code&gt; search fragments. You can now search for all models matching "gemini" and "exp" using &lt;code&gt;llm models -q gemini -q exp&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/748"&gt;#748&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I have over 100 models installed in LLM now across a bunch of different plugins. I added the &lt;code&gt;-q&lt;/code&gt; option to help search through them a few months ago, and now I've upgraded it so you can pass it multiple times.&lt;/p&gt;
&lt;p&gt;Want to see all the Gemini experimental models?&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;llm models -q gemini -q exp&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Outputs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GeminiPro: gemini-exp-1114
GeminiPro: gemini-exp-1121
GeminiPro: gemini-exp-1206
GeminiPro: gemini-2.0-flash-exp
GeminiPro: learnlm-1.5-pro-experimental
GeminiPro: gemini-2.0-flash-thinking-exp-1219
GeminiPro: gemini-2.0-flash-thinking-exp-01-21
GeminiPro: gemini-2.0-pro-exp-02-05 (aliases: g2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For consistency I added the same options to the &lt;code&gt;llm embed-models&lt;/code&gt; command, which lists available &lt;a href="https://llm.datasette.io/en/stable/embeddings/cli.html"&gt;embedding models&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="llm-embed-multi-prepend-x"&gt;llm embed-multi --prepend X&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code&gt;llm embed-multi --prepend X&lt;/code&gt; option for prepending a string to each value before it is embedded - useful for models such as &lt;a href="https://huggingface.co/nomic-ai/nomic-embed-text-v2-moe"&gt;nomic-embed-text-v2-moe&lt;/a&gt; that require passages to start with a string like &lt;code&gt;"search_document: "&lt;/code&gt;. &lt;a href="https://github.com/simonw/llm/issues/745"&gt;#745&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This was inspired by my initial experiments with &lt;a href="https://simonwillison.net/2025/Feb/12/nomic-embed-text-v2/"&gt;Nomic Embed Text V2 last week&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="everything-else"&gt;Everything else&lt;/h4&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;response.json()&lt;/code&gt; and &lt;code&gt;response.usage()&lt;/code&gt; methods are &lt;a href="https://llm.datasette.io/en/stable/python-api.html#python-api-underlying-json"&gt;now documented&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Someone asked a question about these methods online, which made me realize they weren't documented. I enjoy promptly turning questions like this into documentation!&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Fixed a bug where conversations that were loaded from the database could not be continued using &lt;code&gt;asyncio&lt;/code&gt; prompts. &lt;a href="https://github.com/simonw/llm/issues/742"&gt;#742&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;This bug was reported by Romain Gehrig. It turned out not to be possible to execute a follow-up prompt in async mode if the previous conversation had been loaded from the database.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;% llm 'hi' --async
Hello! How can I assist you today?
% llm 'now in french' --async -c
Error: 'async for' requires an object with __aiter__ method, got Response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I fixed the bug for the moment, but I'd like to make the whole mechanism of persisting and loading conversations from SQLite part of the documented and supported Python API - it's currently tucked away in CLI-specific internals which aren't safe for people to use in their own code.&lt;/p&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;New plugin for macOS users: &lt;a href="https://github.com/simonw/llm-mlx"&gt;llm-mlx&lt;/a&gt;, which provides &lt;a href="https://simonwillison.net/2025/Feb/15/llm-mlx/"&gt;extremely high performance access&lt;/a&gt; to a wide range of local models using Apple's MLX framework.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Technically not a part of the LLM 0.22 release, but I like using the release notes to help highlight significant new plugins and &lt;strong&gt;llm-mlx&lt;/strong&gt; is fast coming my new favorite way to run models on my own machine.&lt;/p&gt;


&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;llm-claude-3&lt;/code&gt; plugin has been renamed to &lt;a href="https://github.com/simonw/llm-anthropic"&gt;llm-anthropic&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;I wrote about this previously when I &lt;a href="https://simonwillison.net/2025/Feb/2/llm-anthropic/"&gt;announced llm-anthropic&lt;/a&gt;. The new name prepares me for a world in which Anthropic release models that aren't called Claude 3 or Claude 3.5!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/cli"&gt;cli&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/annotated-release-notes"&gt;annotated-release-notes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&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/chatgpt"&gt;chatgpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &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/gemini"&gt;gemini&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="cli"/><category term="projects"/><category term="ai"/><category term="annotated-release-notes"/><category term="openai"/><category term="generative-ai"/><category term="chatgpt"/><category term="llms"/><category term="llm"/><category term="anthropic"/><category term="gemini"/></entry></feed>