<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>http://til.simonwillison.net/tils/feed.atom</id>
  <title>Simon Willison TIL</title>
  <updated>2026-10-06T19:07:31+00:00</updated>
  <link href="http://til.simonwillison.net/tils/feed.atom" rel="self"/>
  <generator uri="https://github.com/simonw/datasette" version="0.65.5">Datasette</generator>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:datasette_datasette-parseable-opentelemetry.md</id>
    <title>Using Parseable with Datasette for OpenTelemetry traces</title>
    <updated>2026-10-06T19:07:31+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;&lt;a href="https://www.parseable.com" rel="nofollow"&gt;Parseable&lt;/a&gt; is a brand new observability tool, compatible with &lt;a href="https://opentelemetry.io" rel="nofollow"&gt;OpenTelemetry&lt;/a&gt;, for storing and querying observability data.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://docs.datasette.io/en/latest/changelog.html#a41-2026-09-24" rel="nofollow"&gt;Datasette 1.0a41&lt;/a&gt; added support for OpenTelemetry tracing, contributed by &lt;a href="https://alexgarcia.xyz" rel="nofollow"&gt;Alex Garcia&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Here's how I ran the two together, giving me neat local visualizations of traces of Datasette responses.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Installing Parseable&lt;/h2&gt;&lt;a id="user-content-installing-parseable" class="anchor" aria-label="Permalink: Installing Parseable" href="#installing-parseable"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I used the open source release of Parseable from their GitHub releases - I tried &lt;a href="https://github.com/parseablehq/parseable/releases/tag/v3.2.4"&gt;v3.2.4&lt;/a&gt;. They have a macOS Apple Silicon release (a 171MB download) which I downloaded and ran like so:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -fL https://github.com/parseablehq/parseable/releases/download/v3.2.4/Parseable_OSS_aarch64-apple-darwin -o parseable
chmod +x parseable
./parseable local-store&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Running &lt;code&gt;./parseable local-store&lt;/code&gt; starts Parseable with all of the default settings, and stores data in &lt;code&gt;./data&lt;/code&gt; and &lt;code&gt;./staging&lt;/code&gt; directories. It uses ports 8000, 8001, and 8002.&lt;/p&gt;
&lt;p&gt;Then visit &lt;a href="http://localhost:8000/" rel="nofollow"&gt;http://localhost:8000/&lt;/a&gt; and sign in with the default &lt;code&gt;admin/admin&lt;/code&gt; username and password.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Running Datasette&lt;/h2&gt;&lt;a id="user-content-running-datasette" class="anchor" aria-label="Permalink: Running Datasette" href="#running-datasette"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I fetched a test database for Datasette to use:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;wget https://latest.datasette.io/fixtures.db&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then I started Datasette like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;OTEL_SERVICE_NAME=datasette-local \
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:8000 \
OTEL_EXPORTER_OTLP_TRACES_HEADERS='Authorization=Basic%20YWRtaW46YWRtaW4%3D,X-P-Stream=datasette_traces' \
OTEL_TRACES_EXPORTER=otlp_json_http \
OTEL_METRICS_EXPORTER=none \
OTEL_LOGS_EXPORTER=none \
uvx --from 'opentelemetry-instrumentation==0.66b1' \
  --with 'opentelemetry-distro==0.66b1' \
  --with 'datasette==1.0a41' \
  --with 'opentelemetry-exporter-otlp-json-http==0.66b1' \
  opentelemetry-instrument datasette fixtures.db -p 8004
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that we are &lt;em&gt;not&lt;/em&gt; starting &lt;code&gt;datasette&lt;/code&gt; directly - we are instead starting &lt;code&gt;opentelemetry-instrument datasette ...&lt;/code&gt; to properly initialize the tracing.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Viewing a trace&lt;/h2&gt;&lt;a id="user-content-viewing-a-trace" class="anchor" aria-label="Permalink: Viewing a trace" href="#viewing-a-trace"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Load up &lt;a href="http://localhost:8004/" rel="nofollow"&gt;http://localhost:8004/&lt;/a&gt; and click around a bit, then wait a few seconds for the traces to be sent to Parseable. Then navigate to "traces" in the Parseable web interface and you should be able to find things like this:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/datasette/datasette-parsable.webp"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/datasette/datasette-parsable.webp" alt="Screenshot of a trace detail view in an observability web app, with a span waterfall overlaid on a dimmed navigation sidebar and filter column. Dimmed sidebar: breadcrumb &amp;quot;Community &amp;gt; Traces &amp;gt; datas&amp;quot; (cut off), search box &amp;quot;Search... ⌘K&amp;quot;, nav items &amp;quot;Home&amp;quot;, &amp;quot;Ingest telemetry&amp;quot;, section &amp;quot;ANALYZE&amp;quot;: &amp;quot;Keystone&amp;quot;, &amp;quot;Dashboards&amp;quot;, &amp;quot;SQL Editor&amp;quot;, section &amp;quot;OBSERVE&amp;quot;: &amp;quot;Logs&amp;quot;, &amp;quot;Metrics&amp;quot;, &amp;quot;Traces&amp;quot; (selected), &amp;quot;APM&amp;quot;, &amp;quot;Agents&amp;quot;, section &amp;quot;MONITOR&amp;quot;: &amp;quot;Alerts&amp;quot;, &amp;quot;Errors&amp;quot;, section &amp;quot;DATA&amp;quot;: &amp;quot;Datasets&amp;quot;, and at the bottom &amp;quot;Settings&amp;quot;, &amp;quot;Book a call&amp;quot;, &amp;quot;Support&amp;quot;. Dimmed filter column, cut off at the right edge: &amp;quot;Search fi&amp;quot;, &amp;quot;Core&amp;quot;, &amp;quot;Log format&amp;quot;, &amp;quot;User agent&amp;quot;, &amp;quot;Source IPs&amp;quot;, &amp;quot;Error&amp;quot;, &amp;quot;Service&amp;quot;, &amp;quot;service.ins&amp;quot;, &amp;quot;service.na&amp;quot;, &amp;quot;datasett&amp;quot; (checked), &amp;quot;Span&amp;quot;, &amp;quot;Database&amp;quot;, &amp;quot;HTTP&amp;quot;, &amp;quot;http.reque&amp;quot;, &amp;quot;NULL&amp;quot; (unchecked), &amp;quot;GET&amp;quot; (checked), &amp;quot;http.respo&amp;quot;, &amp;quot;http.route&amp;quot;, &amp;quot;Server&amp;quot;, &amp;quot;server.add&amp;quot;, &amp;quot;Telemetry&amp;quot;, &amp;quot;URL&amp;quot;, &amp;quot;All fields&amp;quot;. Trace panel header: &amp;quot;Trace detail &amp;gt; 6f819a2170bcd1e91c6ea3ae236ec60b&amp;quot; with a copy icon, a &amp;quot;Related logs&amp;quot; button and a close X. Summary: &amp;quot;Start time 6:56 PM, Oct 6, 2026 UTC&amp;quot;, &amp;quot;Duration 40.9 ms&amp;quot;, &amp;quot;Spans 247&amp;quot;. A minimap with axis &amp;quot;0ns 10.2ms 20.5ms 30.7ms 40.9ms&amp;quot; shows many short span bars cascading diagonally from top left toward the lower middle, with a few longer bars. Below is a span table with a &amp;quot;Span name&amp;quot; header, a &amp;quot;Search spans...&amp;quot; box, collapse and expand buttons, and a timeline axis &amp;quot;0ns 10.2ms 20.5ms 30.7ms 40.9ms&amp;quot;. Rows (name, service, duration): root span with collapse toggle &amp;quot;123&amp;quot;, &amp;quot;GET /...&amp;quot; &amp;quot;datasette...&amp;quot; 40.9ms spanning the full timeline; then alternating rows where each &amp;quot;db.query&amp;quot; has a collapse toggle &amp;quot;1&amp;quot;: db.query datasette-local 679µs, db.query.execute datasette-local 278µs, db.query datasette-local 341µs, db.query.execute datasette-local 55µs, db.query datasette-local 357µs, db.query.execute datasette-local 197µs, db.query datasette-local 330µs, db.query.execute datasette-local 73µs, db.query datasette-local 2.26ms, db.query.execute datasette-local 2.02ms, db.query datasette-local 232µs, db.query.execute datasette-local 64µs, db.query datasette-local 207µs, db.query.execute datasette-local 71µs, db.query datasette-local 6.11ms. The child span bars start progressively later across the early part of the timeline." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Each request now has a trace which shows the details of every subsequent SQL query. Consult &lt;a href="https://docs.datasette.io/en/latest/internals.html#internals-telemetry" rel="nofollow"&gt;the Datasette OpenTelemetry docs&lt;/a&gt; for more details.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Alternative ports&lt;/h2&gt;&lt;a id="user-content-alternative-ports" class="anchor" aria-label="Permalink: Alternative ports" href="#alternative-ports"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;To run Parseable on ports other than 8000, 8001, and 8002:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;P_ADDR=127.0.0.1:18000 ./parseable local-store \
  --grpc-port 18001 \
  --flight-port 18002&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You'll need to also update Datasette's environment variable to point at the new port:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:18000&lt;/pre&gt;&lt;/div&gt;
</content>
    <link href="https://til.simonwillison.net/datasette/datasette-parseable-opentelemetry"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_blender-coding-agents-macos.md</id>
    <title>Using Blender with coding agents on macOS</title>
    <updated>2026-09-05T15:42:28+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;Modern frontier models have got &lt;em&gt;really good&lt;/em&gt; at using Blender. I've been having a lot of fun trying this out recently - models can produce &lt;code&gt;.blend&lt;/code&gt; files you can edit in Blender itself, and can also render images and even movies (by rendering a sequence of images and combining them with &lt;code&gt;ffmpeg&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Setting this up, at least on macOS, is &lt;em&gt;really easy&lt;/em&gt;. Install the Blender desktop app from &lt;a href="https://www.blender.org" rel="nofollow"&gt;blender.org&lt;/a&gt; and then tell the coding agent:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Use the already install /Applications/Blender to render a scene of a pelican riding a bicycle&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That worked for me with GPT-6 Astra. You can also be a bit more explicit, to save the model some time figuring out how to use it:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Use Blender like this: /Applications/Blender.app/Contents/MacOS/Blender --background --python scene.py&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;A pelican riding a bicycle with GPT-6 Astra&lt;/h2&gt;&lt;a id="user-content-a-pelican-riding-a-bicycle-with-gpt-6-astra" class="anchor" aria-label="Permalink: A pelican riding a bicycle with GPT-6 Astra" href="#a-pelican-riding-a-bicycle-with-gpt-6-astra"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I used GPT-6 Astra (Medium) in the ChatGPT macOS application, Codex mode:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Use the already install /Applications/Blender to render a scene of a pelican riding a bicycle&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;2m39s later:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/gpt-6-astra-blender-pelican-bicycle/refs/heads/main/outputs/pelican-bicycle.png"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/gpt-6-astra-blender-pelican-bicycle/refs/heads/main/outputs/pelican-bicycle.png" alt="A whimsical 3D illustration of a white pelican riding a turquoise bicycle with cream mudguards and silver spokes. Its wings stretch forward to grip the handlebars, and its long yellow legs bend down to the pedals. A coral scarf streams behind its neck. The pelican has a large glossy eye and a long pale yellow bill with a rounded throat pouch. Soft lighting and a muted sage-green background give the scene a pastel, toy-like appearance." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle/blob/main/outputs/pelican-bicycle.blend"&gt;pelican-bicycle.blend&lt;/a&gt;, &lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle/blob/main/work/pelican_scene.py"&gt;pelican_scene.py&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;OK add a background and a lot of flair&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;3m51s later:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/gpt-6-astra-blender-pelican-bicycle/refs/heads/main/outputs/pelican-bicycle-festival.png"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/gpt-6-astra-blender-pelican-bicycle/refs/heads/main/outputs/pelican-bicycle-festival.png" alt="A whimsical pastel 3D illustration of a white pelican riding a turquoise bicycle along a pink boardwalk. It wears a cream boater hat with a pink band and a coral scarf fluttering behind it, with wings reaching for the handlebars and long yellow legs on the pedals. Three balloons trail behind, and a colorful pinwheel is mounted near the front wheel. Striped beach huts, pink-and-white parasols, and angular palm trees line the beach beneath a teal sky. Pastel triangular bunting hangs overhead, while confetti and curling streamers fill the festive scene." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle/blob/main/outputs/pelican-bicycle-festival.blend"&gt;pelican-bicycle-festival.blend&lt;/a&gt;, &lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle/blob/main/work/pelican_flair.py"&gt;pelican_flair.py&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;OK make it a whole lot better&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;5m59s later:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/gpt-6-astra-blender-pelican-bicycle/refs/heads/main/outputs/pelican-coastal-parade.png"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/gpt-6-astra-blender-pelican-bicycle/refs/heads/main/outputs/pelican-coastal-parade.png" alt="A whimsical 3D illustration of a white pelican cycling along a seaside boardwalk at sunset. It wears a cream boater hat and a coral scarf, with wings on the handlebars and long orange legs reaching the pedals of a turquoise bicycle. A wicker front basket holds pink and white flowers, and three balloons float behind. Pastel bunting stretches overhead between palm trees. Striped beach huts stand beside a teal sea with a small sailboat, beneath a large peach-colored sun. The scene has a softly lit, toy-like style." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle/blob/main/outputs/pelican-coastal-parade.blend"&gt;pelican-coastal-parade.blend&lt;/a&gt;, &lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle/blob/main/work/pelican_final.py"&gt;pelican_final.py&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The project lives &lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle"&gt;in this repo&lt;/a&gt;, and &lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle/blob/main/codex-transcript.md"&gt;here's the exported transcript from Codex&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Creating a skill&lt;/h2&gt;&lt;a id="user-content-creating-a-skill" class="anchor" aria-label="Permalink: Creating a skill" href="#creating-a-skill"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Codex makes it pretty easy to create skills (using its built-in skill creating skill), so I finished up by prompting:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Create a quick skill that describes how to use the currently installed /Application/Blender based on what you learned&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It produced and installed &lt;a href="https://github.com/simonw/gpt-6-astra-blender-pelican-bicycle/blob/main/outputs/blender-local/SKILL.md"&gt;this Markdown skill&lt;/a&gt;, which I have since used for further Blender experiments with prompts like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Use your Blender Local skill to build this scene (attached image)&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content>
    <link href="https://til.simonwillison.net/llms/blender-coding-agents-macos"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_mcp-in-claude-and-chatgpt.md</id>
    <title>Adding a custom MCP server to Claude and ChatGPT</title>
    <updated>2026-07-29T00:13:18+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I've started exploring &lt;a href="https://modelcontextprotocol.io/" rel="nofollow"&gt;MCP&lt;/a&gt; in more detail. The web chat UIs for both Claude and ChatGPT can access MCP servers but it's not obvious how to set them up. Here's what I figured out.&lt;/p&gt;
&lt;p&gt;I'm using my new unauthenticated MCP for my blog - &lt;code&gt;https://datasette.simonwillison.net/-/mcp&lt;/code&gt; - which uses &lt;a href="datasette-mcp"&gt;https://github.com/datasette/datasette-mcp&lt;/a&gt; to allow the execution of read-only SQL queries against a copy of my site's database.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Claude&lt;/h2&gt;&lt;a id="user-content-claude" class="anchor" aria-label="Permalink: Claude" href="#claude"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Adding an MCP in &lt;a href="https://claude.ai/" rel="nofollow"&gt;Claude.ai&lt;/a&gt; is quite straight-forward. Start with the + menu next to the chat prompt and navigate through it like this:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/claude-connectors-menu.webp"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/claude-connectors-menu.webp" alt="The Claude prompt box with the + menu open, showing Add files or photos, Take a screenshot, Add to project, Add from GitHub, Skills, Connectors (highlighted), Add plugins and Web search. The Connectors submenu lists Add connector, Manage connectors and toggles for Descript, Gmail, Google Calendar and simonwillison.net. The Add connector submenu offers Browse connectors and Add custom connector." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The "Add custom connector" modal then contains the necessary fields to add the MCP URL, and optionally configure authentication (I haven't tried this yet):&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/claude-add-custom-connector-dialog.webp"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/claude-add-custom-connector-dialog.webp" alt="Claude's &amp;quot;Add custom connector&amp;quot; modal. The name field contains simonwillison.net and the URL field contains https://datasette.simonwillison.net/-/mcp. Below is a collapsed Advanced settings section, a warning that only connectors from trusted developers should be used, and Cancel and Add buttons." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can then toggle the new MCP on and off in the Connectors menu, shown above.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;ChatGPT&lt;/h2&gt;&lt;a id="user-content-chatgpt" class="anchor" aria-label="Permalink: ChatGPT" href="#chatgpt"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;ChatGPT is a whole lot more complicated. First, you need to enable "Developer mode" for your account in the &lt;a href="https://chatgpt.com/#settings/Security" rel="nofollow"&gt;Security and login&lt;/a&gt; panel:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-settings-developer-mode.webp"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-settings-developer-mode.webp" alt="ChatGPT settings on the Security and login tab. The Developer mode section shows a Developer mode toggle marked &amp;quot;ELEVATED RISK&amp;quot; switched on, described as allowing unverified connectors that could modify or erase data permanently, plus an &amp;quot;Enforce CSP in developer mode&amp;quot; toggle that is off. Above it are Sessions and Advanced security sections including a Lockdown mode toggle." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Having done that, you can add your new MCP. The button for that is hard to find - it's the "+" icon on the top right of the &lt;a href="https://chatgpt.com/plugins" rel="nofollow"&gt;ChatGPT Plugins&lt;/a&gt; directory page:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-plugins-page-add-button.webp"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-plugins-page-add-button.webp" alt="The ChatGPT Plugins page, subtitled &amp;quot;Work with ChatGPT across your favorite tools&amp;quot;, with a row of installed plugin icons. A red arrow points at the + button in the top right next to the plugin search box." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This gives you the "New Plugin" modal. Apparently an MCP is a "Plugin" in the user-facing ChatGPT UI.&lt;/p&gt;
&lt;p&gt;Be sure to set authentication to "No Auth" if the plugin does not need OAuth configured:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-new-plugin-dialog.webp"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-new-plugin-dialog.webp" alt="ChatGPT's &amp;quot;New Plugin&amp;quot; modal. Name is set to simonwillison.net, Description is empty, Connection is set to Server URL with https://datasette.simonwillison.net/-/mcp, and Authentication is No Auth. An orange warning says &amp;quot;Custom MCP servers introduce risk&amp;quot; and a checked box confirms &amp;quot;I understand and want to continue&amp;quot;, noting OpenAI hasn't reviewed the MCP server. A Create button sits in the bottom right." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Activating the MCP is difficult as well. On web I found I had to click the + icon next to the chat and then type the name of the MCP to search for it, then select it so I could run a prompt:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-mcp.gif"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-mcp.gif" alt="Animated demo showing clicking plus, typing simonwillison, selecting the MCP and running the prompt &amp;quot;count the tables&amp;quot;" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The first time you do this it will prompt you to enable the MCP:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-connect-prompt-in-chat.webp"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-connect-prompt-in-chat.webp" alt="A ChatGPT conversation where the prompt &amp;quot;list tables&amp;quot; was sent to the simonwillison.net plugin. ChatGPT replies &amp;quot;I'll inspect the connected Simon Willison database and return its table names&amp;quot; and shows a card headed &amp;quot;Connect simonwillison.net&amp;quot; saying ChatGPT needs access to simonwillison.net to help with this request, with &amp;quot;Not now&amp;quot; and &amp;quot;Connect&amp;quot; buttons." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Then show you this more visually impressive splash screen:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-connect-consent-modal.webp"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/refs/heads/main/llms/chatgpt-connect-consent-modal.webp" alt="A ChatGPT modal headed &amp;quot;Add simonwillison.net to ChatGPT&amp;quot; with the site icon next to the OpenAI logo and a Connect button on a blue gradient. Below are three notes: &amp;quot;Permissions always respected&amp;quot;, &amp;quot;You're in control&amp;quot; and &amp;quot;Connectors may introduce risk&amp;quot;, the last warning that sites may attempt to steal your data." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;After all of this... it works!&lt;/p&gt;
&lt;p&gt;Sadly I was not able to access MCPs from ChatGPT Advanced Voice mode - I was hoping I could connect them to a regular chat and then start voice mode and drive an MCP purely through voice commands, but it looks like they are not available as tools while the voice mode conversation is running.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/llms/mcp-in-claude-and-chatgpt"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:github-actions_uvx-github-actions-cache.md</id>
    <title>Using uvx in GitHub Actions in a cache-friendly way</title>
    <updated>2026-07-14T00:56:20+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I often find myself wanting to run a quick Python tool inside of GitHub Actions using &lt;code&gt;uvx name-of-tool&lt;/code&gt; - but I don't want that to result in a network request to PyPI every time the workflow runs. I want the tool to be fetched the first time and then reused from the GitHub Actions cache for subsequent runs.&lt;/p&gt;
&lt;p&gt;I've tried unsuccessfully to find patterns I like for this in the past, especially given the standard pattern in GitHub Actions of using the hashed contents of a file - often &lt;code&gt;pyproject.toml&lt;/code&gt; or &lt;code&gt;requirements.txt&lt;/code&gt; - as a key for the cache.&lt;/p&gt;
&lt;p&gt;This is usually a good pattern, but for simple scripts I don't want to have to maintain an additional file just to get the cache to work correctly.&lt;/p&gt;
&lt;p&gt;Today (with the &lt;a href="https://chatgpt.com/share/6a5586a8-68d0-83e8-a78a-f01ec2472c58" rel="nofollow"&gt;help of GPT-5.6 Sol&lt;/a&gt;) I finally found a pattern I like.&lt;/p&gt;
&lt;p&gt;My goal was to be able to drop &lt;code&gt;uvx name-of-tool&lt;/code&gt; into a GitHub Actions workflow anywhere I like, while still trusting that the tool would be cached between builds - and could be cache-invalidated if I needed to.&lt;/p&gt;
&lt;p&gt;The key turned out to be the &lt;a href="https://docs.astral.sh/uv/reference/environment/#uv_exclude_newer" rel="nofollow"&gt;UV_EXCLUDE_NEWER&lt;/a&gt; environment variable. This works the same as &lt;code&gt;uvx --exclude-newer DATE&lt;/code&gt;, allowing you to tell &lt;code&gt;uv&lt;/code&gt; to install the most recent package as-of a specific date.&lt;/p&gt;
&lt;p&gt;That date can then also be used as part of the cache key for GitHub Actions! This means you can set the date in the script once and get a repeatable set of installed versions for all of the tools. Then any time you want to bust the cache you can increment the date in that one place:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run tools&lt;/span&gt;

&lt;span class="pl-ent"&gt;on&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;workflow_dispatch&lt;/span&gt;:

&lt;span class="pl-ent"&gt;env&lt;/span&gt;:
  &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Bump this date to allow newer package releases and a fresh cache:&lt;/span&gt;
  &lt;span class="pl-ent"&gt;UV_EXCLUDE_NEWER&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;2026-07-12&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;

&lt;span class="pl-ent"&gt;jobs&lt;/span&gt;:
  &lt;span class="pl-ent"&gt;test&lt;/span&gt;:
    &lt;span class="pl-ent"&gt;runs-on&lt;/span&gt;: &lt;span class="pl-s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="pl-ent"&gt;steps&lt;/span&gt;:
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Install uv and restore cache&lt;/span&gt;
        &lt;span class="pl-ent"&gt;id&lt;/span&gt;: &lt;span class="pl-s"&gt;setup-uv&lt;/span&gt;
        &lt;span class="pl-ent"&gt;uses&lt;/span&gt;: &lt;span class="pl-s"&gt;astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 &lt;/span&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; v8.3.2&lt;/span&gt;
        &lt;span class="pl-ent"&gt;with&lt;/span&gt;:
          &lt;span class="pl-ent"&gt;enable-cache&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
          &lt;span class="pl-ent"&gt;cache-dependency-glob&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;cache-suffix&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;tools-${{ env.UV_EXCLUDE_NEWER }}&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
          &lt;span class="pl-ent"&gt;prune-cache&lt;/span&gt;: &lt;span class="pl-c1"&gt;false&lt;/span&gt;
    
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Require cache-only uv on cache hits&lt;/span&gt;
        &lt;span class="pl-ent"&gt;if&lt;/span&gt;: &lt;span class="pl-s"&gt;steps.setup-uv.outputs.cache-hit == 'true'&lt;/span&gt;
        &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;echo "UV_OFFLINE=1" &amp;gt;&amp;gt; "$GITHUB_ENV"&lt;/span&gt;
    
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run sqlite-utils&lt;/span&gt;
        &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;uvx sqlite-utils --version&lt;/span&gt;
    
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run datasette&lt;/span&gt;
        &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;uvx --pre datasette --version&lt;/span&gt;
    
      - &lt;span class="pl-ent"&gt;name&lt;/span&gt;: &lt;span class="pl-s"&gt;Run LLM&lt;/span&gt;
        &lt;span class="pl-ent"&gt;run&lt;/span&gt;: &lt;span class="pl-s"&gt;uvx llm --version&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/astral-sh/setup-uv"&gt;astral-sh/setup-uv&lt;/a&gt; is Astral's official Action for getting &lt;code&gt;uv&lt;/code&gt;. I'm annoyed that it appears to hit Astral's own &lt;code&gt;releases.astral.sh&lt;/code&gt; site every time it runs but if that's how they want it to work I guess that's on them.&lt;/p&gt;
&lt;p&gt;Those settings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;enable-cache: true&lt;/code&gt; turns on GitHub Actions caching&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache-dependency-glob: ""&lt;/code&gt; disables the feature where it looks for &lt;code&gt;pyproject.toml&lt;/code&gt; or similar to use as a cache key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cache-suffix: "tools-${{ env.UV_EXCLUDE_NEWER }}"&lt;/code&gt; is the bit that uses our single &lt;code&gt;UV_EXCLUDE_NEWER&lt;/code&gt; value for the cache key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prune-cache: false&lt;/code&gt; is necessary because Astral default to deliberately pruning your cache of any downloaded wheels, the exact opposite of what I want!&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I should note that my preferences here go directly against &lt;a href="https://docs.astral.sh/uv/concepts/cache/#caching-in-continuous-integration" rel="nofollow"&gt;what uv advises&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;However, in continuous integration environments, persisting pre-built wheels may be undesirable. With uv, it turns out that it's often faster to &lt;em&gt;omit&lt;/em&gt; pre-built wheels from the cache (and instead re-download them from the registry on each run).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Personally I'd rather suffer from very slightly slower CI builds (presumably because GitHub's cache restore operations are slower than fresh installations from PyPI?) than optimize my builds by hitting the PyPI CDN for every tool execution.&lt;/p&gt;
&lt;p&gt;This block here enforces that the cache is used correctly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;      - name: Require cache-only uv on cache hits
        if: steps.setup-uv.outputs.cache-hit == 'true'
        run: echo "UV_OFFLINE=1" &amp;gt;&amp;gt; "$GITHUB_ENV"
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting that &lt;code&gt;UV_OFFLINE=1&lt;/code&gt; environment variable causes &lt;code&gt;uvx tool-name&lt;/code&gt; to fail if the tool has not been previously installed. We only run that if we got a cache hit from the GitHub Actions cache.&lt;/p&gt;
&lt;p&gt;This means that if you add a new tool to the workflow without also bumping the &lt;code&gt;UV_EXCLUDE_NEWER&lt;/code&gt; date you'll get an error.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/github-actions/uvx-github-actions-cache"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:cloudflare_captcha-on-at-least-one-ampersand.md</id>
    <title>Cloudflare CAPTCHA on at least one ampersand</title>
    <updated>2026-06-16T00:21:36+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I use Cloudflare's CAPTCHA (they call it a "Managed Challenge") on &lt;a href="https://simonwillison.net/search/" rel="nofollow"&gt;simonwillison.net/search/&lt;/a&gt; to prevent crawlers from following every single possible combination of my &lt;a href="https://simonwillison.net/2017/Oct/5/django-postgresql-faceted-search/" rel="nofollow"&gt;faceted search&lt;/a&gt; UI.&lt;/p&gt;
&lt;p&gt;This was getting pretty annoying, since I had to wait for the challenge every time I searched my own site.&lt;/p&gt;
&lt;p&gt;I don't particularly care about regular &lt;code&gt;?q=term&lt;/code&gt; searches. Where things get messy is if a crawler starts hitting every combination of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;?q=term&amp;amp;type=entry&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;?q=term&amp;amp;type=entry&amp;amp;year=2006&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;?q=term&amp;amp;type=entry&amp;amp;year=2006&amp;amp;tag=browsers&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;etc.&lt;/p&gt;
&lt;p&gt;I decided to switch the Cloudflare rules around to activating only on hits to &lt;code&gt;/search/&lt;/code&gt; that included at least one &lt;code&gt;&amp;amp;&lt;/code&gt; in the query string section.&lt;/p&gt;
&lt;p&gt;Here's what that rule expression looks like:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;(http.request.uri.path wildcard r"/search/*" and http.request.uri.query contains "&amp;amp;")&lt;/code&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Trying the Cloudflare MCP&lt;/h2&gt;&lt;a id="user-content-trying-the-cloudflare-mcp" class="anchor" aria-label="Permalink: Trying the Cloudflare MCP" href="#trying-the-cloudflare-mcp"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I originally tried to figure this out using Claude Code and &lt;a href="https://github.com/cloudflare/mcp"&gt;Cloudflare's MCP server&lt;/a&gt;. I got that working by creating a dedicated folder:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;mkdir cloudflare-dev
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; cloudflare-dev&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And then setting up the MCP so it would only be active for Claude Code sessions started in that folder:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;echo&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{&lt;/span&gt;
&lt;span class="pl-s"&gt;  "mcpServers": {&lt;/span&gt;
&lt;span class="pl-s"&gt;    "cloudflare-api": {&lt;/span&gt;
&lt;span class="pl-s"&gt;      "type": "http",&lt;/span&gt;
&lt;span class="pl-s"&gt;      "url": "https://mcp.cloudflare.com/mcp"&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;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; .mcp.json
mkdir .claude
&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;{&lt;/span&gt;
&lt;span class="pl-s"&gt;  "enabledMcpjsonServers": [&lt;/span&gt;
&lt;span class="pl-s"&gt;    "cloudflare-api"&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;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; .claude/settings.local.json&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;(I actually set it up by pasting the MCP JSON into Claude Code and saying "set this up to only work in this project folder", but the above is effectively what it did.)&lt;/p&gt;
&lt;p&gt;Then I ran &lt;code&gt;claude&lt;/code&gt; in the folder and used the &lt;code&gt;/mcp&lt;/code&gt; command, selected the Cloudflare MCP and used the authenticate option to jump through an OAuth flow.&lt;/p&gt;
&lt;p&gt;... which didn't work, because as far as I can tell Cloudflare's MCP doesn't yet implement tools to view and modify the rules in question.&lt;/p&gt;
&lt;p&gt;Claude did suggest using the API instead, but I'd need an API token.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using the API instead&lt;/h2&gt;&lt;a id="user-content-using-the-api-instead" class="anchor" aria-label="Permalink: Using the API instead" href="#using-the-api-instead"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I created an API token using &lt;a href="https://dash.cloudflare.com/profile/api-tokens" rel="nofollow"&gt;dash.cloudflare.com/profile/api-tokens&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Cloudflare have a template for "Read all resources", and it turns out you can use that as a starting point.&lt;/p&gt;
&lt;p&gt;I flipped the "Zone WAF" one to "Edit" and set the key to expire tomorrow. Then I copied the resulting key into a &lt;code&gt;token.txt&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;(In the Cloudflare dashboard I believe this feature is called "Web Application Firewall &amp;gt; Custom rules".)&lt;/p&gt;
&lt;p&gt;Then I let Claude Code handle the rest. Here's a rough version of what it did, assuming a token in a &lt;code&gt;$TOKEN&lt;/code&gt; environment variable:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;export&lt;/span&gt; TOKEN=&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;cat token.txt&lt;span class="pl-pds"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
curl -s -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$TOKEN&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://api.cloudflare.com/client/v4/zones?name=simonwillison.net&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-k"&gt;|&lt;/span&gt; jq &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{success, errors, zones: [.result[] | {id, name}]}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This got back the zone ID, which is &lt;code&gt;2ce4f4f41f239d041e25f8320ad3c3fd&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then to list the custom WAF rules:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;export&lt;/span&gt; ZONE=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;2ce4f4f41f239d041e25f8320ad3c3fd&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
curl -s -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$TOKEN&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://api.cloudflare.com/client/v4/zones/&lt;span class="pl-smi"&gt;$ZONE&lt;/span&gt;/rulesets/phases/http_request_firewall_custom/entrypoint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-k"&gt;|&lt;/span&gt; jq &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{success, errors, rules: [.result.rules[]? | {description, action, expression, enabled}]}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This started with:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"success"&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"errors"&lt;/span&gt;: [],
  &lt;span class="pl-ent"&gt;"rules"&lt;/span&gt;: [
    {
      &lt;span class="pl-ent"&gt;"description"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;/search/ extra protection&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"action"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;managed_challenge&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"expression"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;(http.request.uri.path wildcard r&lt;span class="pl-cce"&gt;\"&lt;/span&gt;/search/*&lt;span class="pl-cce"&gt;\"&lt;/span&gt;)&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-ent"&gt;"enabled"&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
    },&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;To edit that rule via API we need the ruleset ID and the rule ID:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -s -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$TOKEN&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://api.cloudflare.com/client/v4/zones/&lt;span class="pl-smi"&gt;$ZONE&lt;/span&gt;/rulesets/phases/http_request_firewall_custom/entrypoint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  &lt;span class="pl-k"&gt;|&lt;/span&gt; jq &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{ruleset_id: .result.id, rule: (.result.rules[] | select(.description=="/search/ extra protection") | {id, description, action, expression, enabled})}&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Returning:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"ruleset_id"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;0682fdbd40cc444cbe1e93d136e2b174&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"rule"&lt;/span&gt;: {
    &lt;span class="pl-ent"&gt;"id"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;8b2766d7802e4e988163531670976cb9&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"description"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;/search/ extra protection&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"action"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;managed_challenge&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"expression"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;(http.request.uri.path wildcard r&lt;span class="pl-cce"&gt;\"&lt;/span&gt;/search/*&lt;span class="pl-cce"&gt;\"&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
    &lt;span class="pl-ent"&gt;"enabled"&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt;
  }
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;And finally we can update that with the new expression:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;export&lt;/span&gt; RS=0682fdbd40cc444cbe1e93d136e2b174
&lt;span class="pl-k"&gt;export&lt;/span&gt; RULE=8b2766d7802e4e988163531670976cb9

curl -s -X PATCH \
  -H &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Authorization: Bearer &lt;span class="pl-smi"&gt;$TOKEN&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; \
  &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://api.cloudflare.com/client/v4/zones/&lt;span class="pl-smi"&gt;$ZONE&lt;/span&gt;/rulesets/&lt;span class="pl-smi"&gt;$RS&lt;/span&gt;/rules/&lt;span class="pl-smi"&gt;$RULE&lt;/span&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
  --data &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;{&lt;/span&gt;
&lt;span class="pl-s"&gt;    "action": "managed_challenge",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "expression": "(http.request.uri.path wildcard r\"/search/*\" and http.request.uri.query contains \"&amp;amp;\")",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "description": "/search/ extra protection",&lt;/span&gt;
&lt;span class="pl-s"&gt;    "enabled": true&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;
</content>
    <link href="https://til.simonwillison.net/cloudflare/captcha-on-at-least-one-ampersand"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_agentsview-custom-model-price.md</id>
    <title>Setting a custom price for a model in AgentsView</title>
    <updated>2026-06-09T21:35:31+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I'm a recent convent to &lt;a href="https://www.agentsview.io" rel="nofollow"&gt;AgentsView&lt;/a&gt;, Wes McKinney's (previously of Pandas fame) Python toolkit for analyzing transcripts of coding agents from your own computer.&lt;/p&gt;
&lt;p&gt;AgentsView can calculate your token spending based on those transcripts, across multiple different coding agents (Claude Code, Codex, Pi and more.)&lt;/p&gt;
&lt;p&gt;You can run it via &lt;code&gt;uvx&lt;/code&gt; like this to get as ASCII table of spending numbers in your terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uvx agentsview usage daily
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;DATE        INPUT     OUTPUT   CACHE_CR  CACHE_RD    COST      MODELS
----        -----     ------   --------  --------    ----      ------
...
2026-06-04  5248968   387578   149428    78087993    $77.72    gpt-5.5, claude-opus-4-8
2026-06-05  963595    115773   0         8383360     $8.62     gpt-5.5, gpt-5.4-mini, deepseek/deepseek-v4-flash, meta-llama/llama-3.2-3b-instruct:free, nvidia/nemotron-nano-9b-v2:free
2026-06-06  324888    36869    0         3231872     $4.35     gpt-5.5
2026-06-07  669027    46379    0         7486464     $8.48     gpt-5.5
2026-06-08  3346262   253637   0         49214208    $48.95    gpt-5.5
2026-06-09  787043    427018   1098673   78305086    $97.79    claude-fable-5, gpt-5.5, claude-haiku-4-5-20251001
----        -----     ------   --------  --------    ----      ------
TOTAL       73610282  7260576  4399393   1717538611  $1472.87  
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anthropic's latest model &lt;a href="https://www.anthropic.com/news/claude-fable-5-mythos-5" rel="nofollow"&gt;Claude Fable 5&lt;/a&gt; came out today. The pricing data AgentsView uses doesn't yet include that model.&lt;/p&gt;
&lt;p&gt;So I had &lt;a href="https://claude.ai/share/28310314-36fe-4bc7-bf04-4060714ed5de" rel="nofollow"&gt;Claude Fable 5 reverse engineer AgentsView&lt;/a&gt; with the following prompt against &lt;a href="https://claude.ai" rel="nofollow"&gt;claude.ai&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone https://github.com/kenn-io/agentsview and tell me how I can set a price for a model that it does not know the price of yet&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It turns out you can add custom pricing information to your &lt;code&gt;~/.agentsview/config.toml&lt;/code&gt; file. Here's what I added:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;custom_model_pricing&lt;/span&gt;.&lt;span class="pl-en"&gt;"claude-fable-5"&lt;/span&gt;]
&lt;span class="pl-smi"&gt;input&lt;/span&gt; = &lt;span class="pl-c1"&gt;10.0&lt;/span&gt;
&lt;span class="pl-smi"&gt;output&lt;/span&gt; = &lt;span class="pl-c1"&gt;50.0&lt;/span&gt;
&lt;span class="pl-smi"&gt;cache_creation&lt;/span&gt; = &lt;span class="pl-c1"&gt;12.50&lt;/span&gt;
&lt;span class="pl-smi"&gt;cache_read&lt;/span&gt; = &lt;span class="pl-c1"&gt;1&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I think those numbers are right, I got them &lt;a href="https://platform.claude.com/docs/en/about-claude/pricing" rel="nofollow"&gt;from this pricing page&lt;/a&gt;. Fable is 2x the price of Opus for input and output.&lt;/p&gt;
&lt;p&gt;With the config file edited I can run Fable again to get pricing estimates. I used the &lt;code&gt;serve&lt;/code&gt; command to get a web application on port 8080:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/50f615e359e85b3af02bcaf015882251b52437e73010300ed8757c2ed442a534/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f6167656e7473766965772d6661626c652e6a7067"&gt;&lt;img src="https://camo.githubusercontent.com/50f615e359e85b3af02bcaf015882251b52437e73010300ed8757c2ed442a534/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f6167656e7473766965772d6661626c652e6a7067" alt="Screenshot of a cost analytics dashboard. Cost Attribution - Click to hide from chart - toggle buttons for Project / Model / Agent and Treemap / List. A treemap shows a large red block: prod_datasette_agent $74.06 89.3%, then blue: cloud $3.98 4.8%, teal: datasette $2.81 3.4%, pink: money $1.92 2.3%, and a thin orange sliver. A legend lists 1 prod_datasette_agent $74.06, 2 cloud $3.98, 3 datasette $2.81, 4 money $1.92, 5 simon $0.15. Below left, Top Sessions by Cost: 1 Claude - Review ./datasette-agent and ./datasette-apps - we are going to a... - prod_datasette_agent · 08a1f374-0e77-420f-be2d-af805d67e8aa - 55.9M $74.06; 2 Claude - issues.db is a copy of the Datasette issues database. There are a... - datasette · 8caa2d2d-b91f-43b3-bf3a-4268995b6011 - 826.8k $2.81; 3 Claude - Consult fly-docs and then look at datasette.cloud (which launche... - cloud · bfcacc70-09d7-4b27-aaec-4bb8accd9fec - 924.7k $2.61; 4 Claude - simonwillisonblog.db is a copy of my blog, plus all my software re... - money · 0c0fb9dc-6347-4e1b-9307-3709a7cdf0c8 - 542.9k $1.92; 5 Claude - Look in datasette.cloud and figure out all remaining steps and dec... - cloud · 45963b5f-608a-4caa-ad6b-6ae81e1dbf0d - 455k $1.37; 6 Claude - simon - simon · deeccb5d-9e90-4b1e-bfe6-c2b271e1b1d4 - 26.4k $0.15. Below right, Cache Efficiency with horizontal bars: Cache Reads 57.6M (nearly full green bar), Cache Writes 769.3K, Uncached Input 64.4K, Output 300.9K (all tiny bars), and a green highlighted note: $516.62 saved vs uncached." data-canonical-src="https://static.simonwillison.net/static/2026/agentsview-fable.jpg" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I've used the equivalent of $82.92 in tokens since getting access to Fable 5 about four and a half hours ago. This is all included in my $100/month Claude Max subscription though, which &lt;a href="https://simonwillison.net/2026/May/27/product-market-fit/#enterprise-customers-are-now-paying-api-prices" rel="nofollow"&gt;based on prior experience&lt;/a&gt; will likely give me around 10x the token usage compared to if I was paying list price.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/llms/agentsview-custom-model-price"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_llm-shebang.md</id>
    <title>Using LLM in the shebang line of a script</title>
    <updated>2026-05-11T18:48:57+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;&lt;a href="https://news.ycombinator.com/item?id=48073246#48090590" rel="nofollow"&gt;This comment&lt;/a&gt; on Hacker News inspired me to investigate patterns for using my &lt;a href="https://llm.datasette.io/" rel="nofollow"&gt;LLM&lt;/a&gt; CLI tool in a shebang line:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;But seriously, you can put a shebang on an english text file now (if you're sufficiently brave) [...]&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;LLM can end up installed in all sorts of unpredictable places so the best way to run it is via the &lt;code&gt;#!/usr/bin/env&lt;/code&gt; pattern.&lt;/p&gt;
&lt;p&gt;Here's how to make English (or Spanish or any other language) text executable via LLM:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env -S llm -f
Generate an SVG of a pelican riding a bicycle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save this as &lt;code&gt;pelican.sh&lt;/code&gt; and make it executable with:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;chmod +x pelican.sh&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then run it:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;./pelican.sh&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Other arguments will be passed through to LLM, so if you want to use a different model:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;./pelican.sh -m gpt-5.4-nano&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This prompt often returns commentary in addition to an SVG. To extract just the first code block in the response add the &lt;code&gt;-x&lt;/code&gt; LLM option:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env -S llm -x -f
Generate an SVG of a pelican riding a bicycle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-f&lt;/code&gt; option needs to come last as it will be passed the path to the script file.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;How this works&lt;/h2&gt;&lt;a id="user-content-how-this-works" class="anchor" aria-label="Permalink: How this works" href="#how-this-works"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env -S llm -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-S&lt;/code&gt; (for split) option to &lt;code&gt;env&lt;/code&gt; is required because, without it, the &lt;code&gt;env&lt;/code&gt; command will treat the rest of the line as the full name of the command, producing this error:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;/usr/bin/env: 'llm -f': No such file or directory&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;With &lt;code&gt;-S&lt;/code&gt; the &lt;code&gt;-f&lt;/code&gt; is passed as an argument to LLM, and then the path to the file itself is passed after that:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;llm -f path/to/pelican.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This takes advantage of &lt;a href="https://llm.datasette.io/en/stable/fragments.html" rel="nofollow"&gt;LLM's fragments mechanism&lt;/a&gt;. The argument to &lt;code&gt;-f&lt;/code&gt; is the path to a file, and the contents of that file will then be appended to the prompt.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Adding tools&lt;/h2&gt;&lt;a id="user-content-adding-tools" class="anchor" aria-label="Permalink: Adding tools" href="#adding-tools"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Scripts like this are a lot more interesting if they can execute tools.&lt;/p&gt;
&lt;p&gt;LLM has some &lt;a href="https://llm.datasette.io/en/stable/tools.html#default-tools" rel="nofollow"&gt;default tools&lt;/a&gt; which you can try out. Here's how to use the &lt;code&gt;llm_time&lt;/code&gt; tool which makes the current time available for the model to call:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env -S llm -T llm_time -f
Write a haiku that mentions the exact current time
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I got (at 17:52 UTC):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Whispers of the hour,&lt;br&gt;
Seventeen fifty-two chimes,&lt;br&gt;
Time flows ever on.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using templates&lt;/h2&gt;&lt;a id="user-content-using-templates" class="anchor" aria-label="Permalink: Using templates" href="#using-templates"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;LLM supports &lt;a href="https://llm.datasette.io/en/stable/templates.html" rel="nofollow"&gt;templates&lt;/a&gt; - YAML files that can mix a prompt, system prompt, model options, and tool definitions.&lt;/p&gt;
&lt;p&gt;These can be used with a shebang line by ending that line with a &lt;code&gt;-t&lt;/code&gt;, for example:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt;!/usr/bin/env -S llm -t&lt;/span&gt;
&lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;Write a haiku&lt;/span&gt;
&lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;Output Spanish&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I got this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Brisa en el bosque,&lt;br&gt;
hojas susurran sueños,&lt;br&gt;
paz en el silencio.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Templates can include parameters, for example:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt;!/usr/bin/env -S llm -t&lt;/span&gt;
&lt;span class="pl-ent"&gt;prompt&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  Two line poem about $animal who lives in $place&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This needs to be run like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;./poem.sh -p animal skunk -p place &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;hovercraft port&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;In hovercraft's hum, where the engines start,&lt;br&gt;
A skunk claims his kingdom, with pride and art.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Templates with tools&lt;/h2&gt;&lt;a id="user-content-templates-with-tools" class="anchor" aria-label="Permalink: Templates with tools" href="#templates-with-tools"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The most interesting way to use templates is with embedded tool functions. Here's a simple example of that, saved as &lt;code&gt;calc.sh&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt;!/usr/bin/env -S llm -t&lt;/span&gt;
&lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gpt-5.4-mini&lt;/span&gt;
&lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  Use tools to run calculations&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;&lt;span class="pl-ent"&gt;functions&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  def add(a: int, b: int) -&amp;gt; int:&lt;/span&gt;
&lt;span class="pl-s"&gt;      return a + b&lt;/span&gt;
&lt;span class="pl-s"&gt;  def multiply(a: int, b: int) -&amp;gt; int:&lt;/span&gt;
&lt;span class="pl-s"&gt;      return a * b&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;chmod 755 calc.sh
./calc.sh &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;what is 2344 * 5252 + 134&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; --td&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Which outputs (thanks to the &lt;code&gt;--td&lt;/code&gt; tool debug option):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Tool call: multiply({'a': 2344, 'b': 5252})
  12310688

Tool call: add({'a': 12310688, 'b': 134})
  12310822

2344 × 5252 + 134 = **12,310,822**
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's a more complex example which defines a tool for searching my blog:&lt;/p&gt;
&lt;div class="highlight highlight-source-yaml"&gt;&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt;!/usr/bin/env -S llm -t&lt;/span&gt;
&lt;span class="pl-ent"&gt;model&lt;/span&gt;: &lt;span class="pl-s"&gt;gpt-5.5&lt;/span&gt;
&lt;span class="pl-ent"&gt;system&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  You answer questions from Simon Willison's blog&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;&lt;span class="pl-ent"&gt;functions&lt;/span&gt;: &lt;span class="pl-s"&gt;|&lt;/span&gt;
&lt;span class="pl-s"&gt;  import httpx&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;  url = "https://datasette.simonwillison.net/simonwillisonblog.json"&lt;/span&gt;
&lt;span class="pl-s"&gt;  sql = """&lt;/span&gt;
&lt;span class="pl-s"&gt;  WITH results AS (&lt;/span&gt;
&lt;span class="pl-s"&gt;    SELECT 'entry' AS type, blog_entry.id AS id, blog_entry.slug AS slug,&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_entry.title AS title, blog_entry.created AS created,&lt;/span&gt;
&lt;span class="pl-s"&gt;           snippet(blog_entry_fts, -1, '&amp;lt;mark&amp;gt;', '&amp;lt;/mark&amp;gt;', '…', 100) AS snippet,&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_entry_fts.rank AS rank&lt;/span&gt;
&lt;span class="pl-s"&gt;    FROM blog_entry_fts JOIN blog_entry ON blog_entry.rowid = blog_entry_fts.rowid&lt;/span&gt;
&lt;span class="pl-s"&gt;    WHERE blog_entry_fts MATCH :q&lt;/span&gt;
&lt;span class="pl-s"&gt;    UNION ALL&lt;/span&gt;
&lt;span class="pl-s"&gt;    SELECT 'blogmark', blog_blogmark.id, blog_blogmark.slug,&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_blogmark.link_title, blog_blogmark.created,&lt;/span&gt;
&lt;span class="pl-s"&gt;           snippet(blog_blogmark_fts, -1, '&amp;lt;mark&amp;gt;', '&amp;lt;/mark&amp;gt;', '…', 100),&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_blogmark_fts.rank&lt;/span&gt;
&lt;span class="pl-s"&gt;    FROM blog_blogmark_fts JOIN blog_blogmark ON blog_blogmark.rowid = blog_blogmark_fts.rowid&lt;/span&gt;
&lt;span class="pl-s"&gt;    WHERE blog_blogmark_fts MATCH :q&lt;/span&gt;
&lt;span class="pl-s"&gt;    UNION ALL&lt;/span&gt;
&lt;span class="pl-s"&gt;    SELECT 'quotation', blog_quotation.id, blog_quotation.slug,&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_quotation.source, blog_quotation.created,&lt;/span&gt;
&lt;span class="pl-s"&gt;           snippet(blog_quotation_fts, -1, '&amp;lt;mark&amp;gt;', '&amp;lt;/mark&amp;gt;', '…', 100),&lt;/span&gt;
&lt;span class="pl-s"&gt;           blog_quotation_fts.rank&lt;/span&gt;
&lt;span class="pl-s"&gt;    FROM blog_quotation_fts JOIN blog_quotation ON blog_quotation.rowid = blog_quotation_fts.rowid&lt;/span&gt;
&lt;span class="pl-s"&gt;    WHERE blog_quotation_fts MATCH :q&lt;/span&gt;
&lt;span class="pl-s"&gt;    UNION ALL&lt;/span&gt;
&lt;span class="pl-s"&gt;    SELECT 'note', id, slug, title, created,&lt;/span&gt;
&lt;span class="pl-s"&gt;           -- crude snippet: ~100 chars around the first match&lt;/span&gt;
&lt;span class="pl-s"&gt;           -- because notes do not yet have FTS enabled&lt;/span&gt;
&lt;span class="pl-s"&gt;           '…' || substr(body, max(1, instr(lower(body), lower(:q)) - 40), 200) || '…',&lt;/span&gt;
&lt;span class="pl-s"&gt;           0.0  -- no real rank available&lt;/span&gt;
&lt;span class="pl-s"&gt;    FROM blog_note&lt;/span&gt;
&lt;span class="pl-s"&gt;    WHERE body LIKE '%' || :q || '%' OR title LIKE '%' || :q || '%'&lt;/span&gt;
&lt;span class="pl-s"&gt;  )&lt;/span&gt;
&lt;span class="pl-s"&gt;  SELECT snippet FROM results&lt;/span&gt;
&lt;span class="pl-s"&gt;  ORDER BY rank&lt;/span&gt;
&lt;span class="pl-s"&gt;  LIMIT 20&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;  def search_blog(query: str) -&amp;gt; str:&lt;/span&gt;
&lt;span class="pl-s"&gt;      """Search Simon's blog"""&lt;/span&gt;
&lt;span class="pl-s"&gt;      return httpx.get(url, params={"sql": sql, "q": query}).text&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This sets up a tool called &lt;code&gt;search_blog(query)&lt;/code&gt; which then executes an HTTP request against my &lt;a href="https://datasette.simonwillison.net/" rel="nofollow"&gt;datasette.simonwillison.net&lt;/a&gt; Datasette instance carrying a SQL query that searches various types of content.&lt;/p&gt;
&lt;p&gt;Result:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;./blog.sh &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Has Simon implemented GraphQL?&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Yes. Simon implemented GraphQL support for Datasette as a plugin called &lt;strong&gt;&lt;code&gt;datasette-graphql&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;He described it as “a plugin that adds GraphQL query support to Datasette,” and later as a “Datasette plugin providing an automatic GraphQL API for your SQLite databases.” It can expose Datasette tables through GraphQL, including nested fields based on foreign-key relationships.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's the &lt;a href="https://gist.github.com/simonw/551375ddec4dea1994bcaf299c473396"&gt;full log&lt;/a&gt; of that response showing the tool calls that were executed.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/llms/llm-shebang"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:google-sheets_datasette-sql.md</id>
    <title>SQL functions in Google Sheets to fetch data from Datasette</title>
    <updated>2026-04-20T02:33:58+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I've been experimenting with ways to fetch data from Datasette and display it in Google Sheets.&lt;/p&gt;
&lt;p&gt;I've found three patterns that work so far. &lt;code&gt;importdata()&lt;/code&gt; and "named functions" can only fetch from public Datasette instances. Apps Script can fetch from API key protected instances as well.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using IMPORTDATA()&lt;/h2&gt;&lt;a id="user-content-using-importdata" class="anchor" aria-label="Permalink: Using IMPORTDATA()" href="#using-importdata"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The easiest way to get this up and running doesn't involve any custom sheets functions at all. The &lt;a href="https://support.google.com/docs/answer/3093335?hl=en" rel="nofollow"&gt;IMPORTDATA()&lt;/a&gt; default function can fetch any CSV data from a URL and load it into the sheet - and Datasette &lt;a href="https://docs.datasette.io/en/latest/csv_export.html" rel="nofollow"&gt;exports CSV&lt;/a&gt; by default.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://latest.datasette.io/fixtures/roadside_attractions.csv" rel="nofollow"&gt;https://latest.datasette.io/fixtures/roadside_attractions.csv&lt;/a&gt; - the CSV data for the &lt;a href="https://latest.datasette.io/fixtures/roadside_attractions" rel="nofollow"&gt;roadside_attractions&lt;/a&gt; table.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://latest.datasette.io/fixtures/-/query.csv?sql=select+pk%2C+name%2C+address%2C+url%2C+latitude%2C+longitude+from+roadside_attractions" rel="nofollow"&gt;https://latest.datasette.io/fixtures/-/query.csv?sql=select+pk%2C+name%2C+address%2C+url%2C+latitude%2C+longitude+from+roadside_attractions&lt;/a&gt; - a SQL export of a database query, in this case one that returns all rows from that table.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Either of these URLs can be used in a Google Sheets cell like this:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;=importdata("https://latest.datasette.io/fixtures/-/query.csv?sql=select+pk%2C+name%2C+address%2C+url%2C+latitude%2C+longitude+from+roadside_attractions&amp;amp;_size=max")&lt;/code&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using a named function&lt;/h2&gt;&lt;a id="user-content-using-a-named-function" class="anchor" aria-label="Permalink: Using a named function" href="#using-a-named-function"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Ideally I'd like to use &lt;code&gt;=sql("SELECT ...")&lt;/code&gt; in my spreadsheet cells instead. Google Sheets lets you define new "named functions" on a per-sheet basis, which can use existing Sheets functions and formulas - including &lt;code&gt;importdata()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Go to &lt;code&gt;Data -&amp;gt; Named functions&lt;/code&gt; and select "Add new function". Call it &lt;code&gt;SQL&lt;/code&gt; and add a single argument placeholder called &lt;code&gt;query&lt;/code&gt;, then set the following formula definition:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;=IMPORTDATA(
  "https://latest.datasette.io/fixtures/-/query.csv?sql=" &amp;amp;
  ENCODEURL(query)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can use &lt;code&gt;=SQL("select * from roadside_attractions")&lt;/code&gt; in a cell to execute that SQL query and load in the CSV data:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/main/google-sheets/named-function.jpg"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/main/google-sheets/named-function.jpg" alt="Screenshot of Google Sheets. The spreadsheet displays data from a table, with the cell value set to =SQL(&amp;quot;select pk, name, address, url, latitude, longitude from roadside_attractions order by pk limit 101&amp;quot;). The &amp;quot;Edit named function&amp;quot; panel is visible on the right, where a function called SQL takes an argument placeholder &amp;quot;query&amp;quot; and has the IMPORTDATA formula definition shown above." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Using Apps Script&lt;/h2&gt;&lt;a id="user-content-using-apps-script" class="anchor" aria-label="Permalink: Using Apps Script" href="#using-apps-script"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;There's one big downside of &lt;code&gt;importdata()&lt;/code&gt; or a named function built on top of it: only unauthenticated URLs to CSV exports are supported. If your Datasette instance is protected by authentication and requires API keys to be sent as HTTP headers you will not be able to use them.&lt;/p&gt;
&lt;p&gt;(&lt;code&gt;importdata()&lt;/code&gt; can work fine here if the API key is a query string argument though. Here's &lt;a href="https://github.com/simonw/datasette-auth-tokens/blob/main/README.md#api-tokens-as-a-query-string-parameter"&gt;how to enable that&lt;/a&gt; using the &lt;code&gt;datasette-auth-tokens&lt;/code&gt; plugin.)&lt;/p&gt;
&lt;p&gt;&lt;a href="https://developers.google.com/apps-script" rel="nofollow"&gt;Apps Script&lt;/a&gt; lets you define custom server-side JavaScript functions which can then be called from a Google Sheets cell. These can be a lot more flexible, including sending API tokens in HTTP headers.&lt;/p&gt;
&lt;p&gt;To create an Apps Script for a spreadsheet, use "Extensions -&amp;gt; Apps Script". This will start you on a code editor with a &lt;code&gt;Code.gs&lt;/code&gt; file that you can edit. Here's a function definition for a &lt;code&gt;=datasette_sql(query)&lt;/code&gt; custom function:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;datasette_sql&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;query&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;baseUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'https://latest.datasette.io/fixtures'&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;token&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;''&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-c"&gt;// Strip a trailing slash so we control the join&lt;/span&gt;
  &lt;span class="pl-s1"&gt;baseUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;baseUrl&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;replace&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-pds"&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;span class="pl-cce"&gt;\/&lt;/span&gt;&lt;span class="pl-c1"&gt;+&lt;/span&gt;&lt;span class="pl-cce"&gt;$&lt;/span&gt;&lt;span class="pl-c1"&gt;/&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;""&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;url&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;baseUrl&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;"/-/query.json?sql="&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-en"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;query&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;options&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt; &lt;span class="pl-c1"&gt;muteHttpExceptions&lt;/span&gt;: &lt;span class="pl-c1"&gt;true&lt;/span&gt; &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;token&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;options&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;headers&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt; &lt;span class="pl-c1"&gt;Authorization&lt;/span&gt;: &lt;span class="pl-s"&gt;"Bearer "&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;token&lt;/span&gt; &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;

  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;response&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;url&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;options&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;json&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;parse&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;response&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getContentText&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;!&lt;/span&gt;&lt;span class="pl-s1"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;ok&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;throw&lt;/span&gt; &lt;span class="pl-k"&gt;new&lt;/span&gt; &lt;span class="pl-v"&gt;Error&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;json&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;error&lt;/span&gt; &lt;span class="pl-c1"&gt;||&lt;/span&gt; &lt;span class="pl-s"&gt;"Query failed"&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;

  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;rows&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-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;rows&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;!&lt;/span&gt;&lt;span class="pl-s1"&gt;rows&lt;/span&gt; &lt;span class="pl-c1"&gt;||&lt;/span&gt; &lt;span class="pl-s1"&gt;rows&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;length&lt;/span&gt; &lt;span class="pl-c1"&gt;===&lt;/span&gt; &lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s"&gt;"No results"&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;cols&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-v"&gt;Object&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;keys&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;rows&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;result&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;cols&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

  &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;i&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt; &lt;span class="pl-s1"&gt;i&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;lt;&lt;/span&gt; &lt;span class="pl-s1"&gt;rows&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;length&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt; &lt;span class="pl-s1"&gt;i&lt;/span&gt;&lt;span class="pl-c1"&gt;++&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;row&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;j&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt; &lt;span class="pl-s1"&gt;j&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;lt;&lt;/span&gt; &lt;span class="pl-s1"&gt;cols&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;length&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt; &lt;span class="pl-s1"&gt;j&lt;/span&gt;&lt;span class="pl-c1"&gt;++&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
      &lt;span class="pl-k"&gt;var&lt;/span&gt; &lt;span class="pl-s1"&gt;val&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;rows&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;i&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;cols&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-s1"&gt;j&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
      &lt;span class="pl-s1"&gt;row&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;val&lt;/span&gt; &lt;span class="pl-c1"&gt;===&lt;/span&gt; &lt;span class="pl-c1"&gt;null&lt;/span&gt; ? &lt;span class="pl-s"&gt;""&lt;/span&gt; : &lt;span class="pl-s1"&gt;val&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-s1"&gt;result&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;push&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;row&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;

  &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;result&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You can set the base URL and an optional API token in variables at the top of the script.&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/simonw/til/main/google-sheets/apps-script-editor.jpg"&gt;&lt;img src="https://raw.githubusercontent.com/simonw/til/main/google-sheets/apps-script-editor.jpg" alt="Apps Script editor UI - lots of menu items, a blue Deploy button and the source code for the Code.gs file." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can ignore that "Deploy" button entirely, it's not necessary for custom functions for sheets. I had to hit the &lt;code&gt;Command+S&lt;/code&gt; key combination to save my changes - confusingly I could not find a "save" button in the editor UI.&lt;/p&gt;
&lt;p&gt;Apps Script has a script and document properties mechanism which theoretically could be used to keep secret values separate from that code, but I wasn't able to get that to work without confusing permission dialogs popping up.&lt;/p&gt;
&lt;p&gt;As far as I can tell users who have "view" permission but not "edit" permission on the spreadsheet are unable to view the source code, so it should be safe to keep read-only API tokens in the source code even for shared spreadsheets.&lt;/p&gt;
&lt;p&gt;I've prepared &lt;a href="https://docs.google.com/spreadsheets/d/14lRV2-AeBmjI3lJbl2apwfC_ncXqL0uSV68lmtzUI7I/edit?gid=0#gid=0" rel="nofollow"&gt;this demo sheet&lt;/a&gt; showing all three of the above solutions - &lt;code&gt;importdata()&lt;/code&gt;, a named &lt;code&gt;sql()&lt;/code&gt; function and a &lt;code&gt;datasette_sql()&lt;/code&gt; function defined using Apps Script.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/google-sheets/datasette-sql"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:llms_openclaw-docker.md</id>
    <title>Running OpenClaw in Docker</title>
    <updated>2026-02-01T23:59:07+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I'm not brave enough to run &lt;a href="https://openclaw.ai/" rel="nofollow"&gt;OpenClaw&lt;/a&gt; (aka Clawdbot aka Moltbot) directly on my Mac, so I decided to try running it in a Docker instead container.&lt;/p&gt;
&lt;p&gt;OpenClaw has Docker support out of the box, described &lt;a href="https://docs.openclaw.ai/install/docker" rel="nofollow"&gt;on this page&lt;/a&gt; of their documentation. Here's what worked for me.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Use their Docker Compose configuration&lt;/h2&gt;&lt;a id="user-content-use-their-docker-compose-configuration" class="anchor" aria-label="Permalink: Use their Docker Compose configuration" href="#use-their-docker-compose-configuration"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;First, clone their GitHub repository:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;git clone https://github.com/openclaw/openclaw&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It contains a script for running OpenClaw in Docker called &lt;a href="https://github.com/openclaw/openclaw/blob/main/docker-setup.sh"&gt;docker-setup.sh&lt;/a&gt; which in turn uses Docker Compose and their &lt;a href="https://github.com/openclaw/openclaw/blob/main/docker-compose.yml"&gt;docker-compose.yml&lt;/a&gt; file.&lt;/p&gt;
&lt;p&gt;The script will create two folders directly on your Mac which will then be mounted as volumes in the Docker container:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.openclaw&lt;/code&gt; is the configuration directory. This will eventually contain OpenClaw memory, configuration, third party API keys, etc.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/openclaw/workspace&lt;/code&gt; is the workspace directory full of files that are directly available to the agent as it runs inside the container. Files the agent creates will be saved here too.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Answering all of those questions&lt;/h2&gt;&lt;a id="user-content-answering-all-of-those-questions" class="anchor" aria-label="Permalink: Answering all of those questions" href="#answering-all-of-those-questions"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;On first run OpenClaw asks you a &lt;em&gt;lot&lt;/em&gt; of questions. Most of these are reasonably obvious but I still had to start over a couple of times to get everything right. Some that I found tricky:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Onboarding mode: manual&lt;/li&gt;
&lt;li&gt;What do you want to set up?: Local gateway (this machine)&lt;/li&gt;
&lt;li&gt;Model provider. I decided to go for OpenAI Codex with ChatGPT OAuth, which then allowed me to authenticate against ChatGPT to spend tokens already covered by my $20/month OpenAI subscription. I did this because I've heard that OpenClaw can spend a &lt;em&gt;lot&lt;/em&gt; of tokens on API plans, and using ChatGPT put an easy upper limit on how much it could spend. When you opt for this OpenClaw gives you a URL to open in your browser which redirects back to a non-running &lt;code&gt;localhost&lt;/code&gt; service and displays an error message - you then copy and paste that &lt;code&gt;localhost&lt;/code&gt; URL back into OpenClaw to complete the authentication. Here's &lt;a href="https://gisthost.github.io/?6f7671b0c90839f01f63c5a3850656b7" rel="nofollow"&gt;what that looks like&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Tailscale: I tried to configure this the first time and it resulted in a machine I couldn't use, so the second time I said "no".&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once it's up and running you can run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To see the container. Mine is running the image &lt;code&gt;openclaw:local&lt;/code&gt; and has a container name &lt;code&gt;openclaw-openclaw-gateway-1&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Running administrative commands&lt;/h2&gt;&lt;a id="user-content-running-administrative-commands" class="anchor" aria-label="Permalink: Running administrative commands" href="#running-administrative-commands"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The other container provided by Docker Compose is called &lt;code&gt;openclaw-cli&lt;/code&gt; and can be used to run the &lt;a href="https://docs.openclaw.ai/cli" rel="nofollow"&gt;openclaw CLI commands&lt;/a&gt; for managing the instance.&lt;/p&gt;
&lt;p&gt;This works for that, but you &lt;em&gt;must&lt;/em&gt; run it in the same folder as that &lt;code&gt;docker-compose.yml&lt;/code&gt; file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose run --rm openclaw-cli status
&lt;/code&gt;&lt;/pre&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Setting up a Telegram bot&lt;/h2&gt;&lt;a id="user-content-setting-up-a-telegram-bot" class="anchor" aria-label="Permalink: Setting up a Telegram bot" href="#setting-up-a-telegram-bot"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;OpenClaw can communicate via a number of different messaging platforms, including WhatsApp and iMessage and Telegram and Slack and Discord. This means you can control the instance in your container directly from your phone.&lt;/p&gt;
&lt;p&gt;I decided Telegram looked like the least hassle to set up.&lt;/p&gt;
&lt;p&gt;You'll need a Telegram account. Then create a new bot by chatting to &lt;a href="https://t.me/BotFather" rel="nofollow"&gt;@BotFather&lt;/a&gt; on Telegram.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Start a chat with @BotFather&lt;/li&gt;
&lt;li&gt;Send the command &lt;code&gt;/newbot&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Follow the instructions to name your bot and get a token&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That token can then be provided to OpenClaw as part of the initial setup wizard.&lt;/p&gt;
&lt;p&gt;There's one remaining step: you have to &lt;em&gt;pair&lt;/em&gt; your Telegram account with your new bot and your OpenClaw instance.&lt;/p&gt;
&lt;p&gt;OpenClaw will send you a message via Telegram with the pairing code, then run this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose run --rm openclaw-cli pairing approve telegram &amp;lt;CODE&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point you should be able to message your bot directly from Telegram on your phone!&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Accessing the web UI&lt;/h2&gt;&lt;a id="user-content-accessing-the-web-ui" class="anchor" aria-label="Permalink: Accessing the web UI" href="#accessing-the-web-ui"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;OpenClaw runs a default web UI on port 18789. If you access this directly at &lt;code&gt;http://localhost:18789&lt;/code&gt; you'll see an error telling you that you need to authenticate first.&lt;/p&gt;
&lt;p&gt;To do that you need a special &lt;code&gt;?token=...&lt;/code&gt; URL parameter. This may have been displayed during setup, but if you lose it you can get a new one like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose run --rm openclaw-cli dashboard --no-open
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Follew the URL that spits out to access the interface.&lt;/p&gt;
&lt;p&gt;Sometimes that's not enough either - you may see this error:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;disconnected (1008): pairing required
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For some reason the &lt;code&gt;openclaw-cli&lt;/code&gt; container didn't work for me here, but this alternative way of running the &lt;code&gt;openclaw&lt;/code&gt; commands did:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec openclaw-gateway \
  node dist/index.js devices list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That shows a list of pairings, hopefully including a request that is not yet approved like this one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Pending (1)
┌──────────────────────────────────────┬───────────────────────────────────┬──────────┬────────────┬────────┬────────┐
│ Request                              │ Device                            │ Role     │ IP         │ Age    │ Flags  │
├──────────────────────────────────────┼───────────────────────────────────┼──────────┼────────────┼────────┼────────┤
│ 6f9db1bd-a1cc-4d3f-b643-2c195262464e │ 8b7bbf4f69633058dc3beee8a56adbccf │ operator │ 172.18.0.1 │ 2m ago │        │
│                                      │ aafc8be8058bea8a06be1cb7bfad9b3   │          │            │        │        │
└──────────────────────────────────────┴───────────────────────────────────┴──────────┴────────────┴────────┴────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To approve that, run this command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec openclaw-gateway \
  node dist/index.js devices approve \
  6f9db1bd-a1cc-4d3f-b643-2c195262464e
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dashboard UI looks like this, and has a whole load of different debugging tools plus a web chat interface:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/f8ac37409eae2ba20f461eff3eb91b5756b6f75a207174e5b0681dd9dc2e76c6/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f6f70656e636c61772d7765622d75692e6a7067"&gt;&lt;img src="https://camo.githubusercontent.com/f8ac37409eae2ba20f461eff3eb91b5756b6f75a207174e5b0681dd9dc2e76c6/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f6f70656e636c61772d7765622d75692e6a7067" alt="Screenshot of the OpenClaw Gateway Dashboard web interface. Header shows &amp;quot;OpenCLAW GATEWAY DASHBOARD&amp;quot; with a green &amp;quot;Health OK&amp;quot; indicator. Left sidebar contains navigation sections: Chat (Chat highlighted), Control (Overview, Channels, Instances, Sessions, Cron Jobs), Agent (Skills, Nodes), Settings (Config, Debug, Logs), and Resources (Docs). Main content area displays &amp;quot;Chat&amp;quot; with subtitle &amp;quot;Direct gateway chat session for quick interventions.&amp;quot; and &amp;quot;telegram:6580064359&amp;quot; identifier. A user message at 4:08 PM reads &amp;quot;Show me a detailed list of all your available configured tools&amp;quot;. The assistant response states: &amp;quot;Here's the full list of tools I have available in this OpenClaw session (as configured). These are the only ones I can call programmatically:&amp;quot; followed by categorized tools: &amp;quot;File &amp;amp; workspace&amp;quot; (read — Read a file (text or image). Supports offset/limit for large files; write — Create/overwrite a file (creates parent dirs); edit — Precise in-place edit by exact string replacement), &amp;quot;Shell / processes&amp;quot; (exec — Run a shell command (optionally PTY, backgrounding, timeouts); process — Manage running exec sessions (list/poll/log/write/kill/etc.)), &amp;quot;Web&amp;quot; (web_search — Search the web (Brave Search API); web_fetch — Fetch a URL and extract readable content (markdown/text); browser — Control a browser (open/navigate/snapshot/screenshot/act/etc.)), &amp;quot;UI / rendering&amp;quot; (canvas — Present/eval/snapshot a Canvas surface (for node canvases/UI rendering)), and &amp;quot;Devices / nodes&amp;quot; (cut off). Bottom shows message input with placeholder &amp;quot;Message (↵ to send, Shift+↵ for line breaks, paste images)&amp;quot; and &amp;quot;New session&amp;quot; and coral &amp;quot;Send&amp;quot; buttons." data-canonical-src="https://static.simonwillison.net/static/2026/openclaw-web-ui.jpg" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Running commands as root&lt;/h2&gt;&lt;a id="user-content-running-commands-as-root" class="anchor" aria-label="Permalink: Running commands as root" href="#running-commands-as-root"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;I found myself wanting to install some extra packages, but the OpenClaw bot itself runs as a user without &lt;code&gt;sudo&lt;/code&gt; access (probably for the best!)&lt;/p&gt;
&lt;p&gt;You can access a Bash shell as root like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec -u root openclaw-gateway bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I installed extra packages there like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apt-get update &amp;amp;&amp;amp; apt-get install -y ripgrep
&lt;/code&gt;&lt;/pre&gt;
</content>
    <link href="https://til.simonwillison.net/llms/openclaw-docker"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:cloudflare_response-header-transform-rule.md</id>
    <title>Cloudflare response header transform rules</title>
    <updated>2026-01-23T15:08:25+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I serve Python files from my &lt;code&gt;tools.simonwillison.net&lt;/code&gt; subdomain, which is a GitHub Pages site that's served via Cloudflare. For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://tools.simonwillison.net/python/q3_tts.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is meant to be used with &lt;code&gt;uv&lt;/code&gt; &lt;a href="https://simonwillison.net/2026/Jan/22/qwen3-tts/" rel="nofollow"&gt;as described here&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uv run https://tools.simonwillison.net/python/q3_tts.py \
  'I am a pirate, give me your gold!' \
  -i 'gruff voice' -o pirate.wav
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default files with &lt;code&gt;.py&lt;/code&gt; extensions are served with a &lt;code&gt;content-type: application/octet-stream&lt;/code&gt; header. This means browsers will download them rather than displaying them directly to the user.&lt;/p&gt;
&lt;p&gt;For code that you're going to execute on your machine it's nice to be able to preview it in your browser first!&lt;/p&gt;
&lt;p&gt;Here's that default &lt;code&gt;content-type&lt;/code&gt; header:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % curl -I 'https://tools.simonwillison.net/python/q3_tts.py'
HTTP/2 200 
date: Fri, 23 Jan 2026 14:52:54 GMT
content-type: application/octet-stream
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I can't control how GitHub Pages serves files, but since the site is behind Cloudflare I can fix the problem there instead.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Navigating the Cloudflare dashboard&lt;/h2&gt;&lt;a id="user-content-navigating-the-cloudflare-dashboard" class="anchor" aria-label="Permalink: Navigating the Cloudflare dashboard" href="#navigating-the-cloudflare-dashboard"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The area to look for here is called "Rules". Within that area the "Create rule" button has an option to create a "Response Header Transform Rule":&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/90c6238f4affbc2d454f544ee07e19c6ee5e14f305b37d984de85bc6ced34fd3/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f636c6f7564666c6172652d72756c65732d6d656e752e706e67"&gt;&lt;img src="https://camo.githubusercontent.com/90c6238f4affbc2d454f544ee07e19c6ee5e14f305b37d984de85bc6ced34fd3/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f636c6f7564666c6172652d72756c65732d6d656e752e706e67" alt="Rules Overview page. The Create rule menu is open showing a list of different rule types, including Redirect Rule, URL Rewrite Rule, Request Header Transform rule and Response Header Transform Rule." data-canonical-src="https://static.simonwillison.net/static/2026/cloudflare-rules-menu.png" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I created a rule that looks like this:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/8da058699a8d5d20ba5f0d4bad01779d4e08dea774c2751639057f5cf1ed50ff/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f656469742d687474702d726573706f6e73652d6865616465722e706e67"&gt;&lt;img src="https://camo.githubusercontent.com/8da058699a8d5d20ba5f0d4bad01779d4e08dea774c2751639057f5cf1ed50ff/68747470733a2f2f7374617469632e73696d6f6e77696c6c69736f6e2e6e65742f7374617469632f323032362f656469742d687474702d726573706f6e73652d6865616465722e706e67" alt="Edit HTTP Response Header Transform Rule form - the rule name is set to tools.simonwillison.net .py files to text/plain, it has a Custom filter expression for the incoming requests matching Hostname equals tools.simonwillison.net and URI Path ends with .py - this shows an expression preview of http.host eq &amp;quot;tools.simonwillison.net&amp;quot; and ends_with(http.request.uri.path, &amp;quot;&amp;quot;py&amp;quot;)) - then it uses Set static of content-type to text/plain; charset=utf-8" data-canonical-src="https://static.simonwillison.net/static/2026/edit-http-response-header.png" style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;The response header transform rule&lt;/h2&gt;&lt;a id="user-content-the-response-header-transform-rule" class="anchor" aria-label="Permalink: The response header transform rule" href="#the-response-header-transform-rule"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;My new rule has the following settings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rule name&lt;/strong&gt;: &lt;code&gt;tools.simonwillison.net .py files to text/plain&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When incoming requests match&lt;/strong&gt;: Custom filter expression&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter expression&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;Hostname equals &lt;code&gt;tools.simonwillison.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;AND URI Path ends with &lt;code&gt;.py&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then&lt;/strong&gt;:
&lt;ul&gt;
&lt;li&gt;Set static &lt;code&gt;content-type&lt;/code&gt; to &lt;code&gt;text/plain; charset=utf-8&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I clicked 'Deploy' and within a few seconds saw this result:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~ % curl -I 'https://tools.simonwillison.net/python/q3_tts.py'
HTTP/2 200 
date: Fri, 23 Jan 2026 14:52:58 GMT
content-type: text/plain; charset=utf-8
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And now &lt;a href="https://tools.simonwillison.net/python/q3_tts.py" rel="nofollow"&gt;https://tools.simonwillison.net/python/q3_tts.py&lt;/a&gt; displays the Python code directly in my browser.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/cloudflare/response-header-transform-rule"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:claude-code_preview-github-pages.md</id>
    <title>Previewing Claude Code for web branches with GitHub Pages</title>
    <updated>2026-01-22T16:43:41+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I'm a big user of &lt;a href="https://code.claude.com/docs/en/claude-code-on-the-web" rel="nofollow"&gt;Claude Code on the web&lt;/a&gt;, Anthropic's poorly named cloud-based version of Claude Code which can be driven via the web or their native mobile and desktop applications.&lt;/p&gt;
&lt;p&gt;I mostly use it through the Claude iPhone app.&lt;/p&gt;
&lt;p&gt;The biggest downside of this way of working is that, beyond CLI tools or code libraries, it's difficult to preview its work while it is iterating on the code.&lt;/p&gt;
&lt;p&gt;I've started using GitHub Pages against private repositories to work around this limitation recently  - at least for HTML projects - and it's working really well.&lt;/p&gt;
&lt;p&gt;Here's how I'll spin up a new experimental prototype such that I can preview it on my phone.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a new private repository at &lt;a href="https://github.com/new"&gt;https://github.com/new&lt;/a&gt; - include an empty README here to ensure the repo has been initialized.&lt;/li&gt;
&lt;li&gt;Start a task in Claude Code against that repo - you may need to restart the app to ensure it picks up the newly created repo.&lt;/li&gt;
&lt;li&gt;Tell Claude Code what to build, using a variation on "self-contained HTML file, vanilla JavaScript, no build step, load any dependencies from a CDN" - this ensures that the code it writes will run directly in a browser without needing to mess with npm&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Claude Code will get to work, and after a few minutes will create a branch in your repository. Here's the clever bit:&lt;/p&gt;
&lt;ol start="4"&gt;
&lt;li&gt;Navigate to the Pages area of your repository settings, make sure "Deploy from a branch" is selected (the default) and then open the branch picker and select the &lt;code&gt;claude/...&lt;/code&gt; branch that Claude Code just created&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Wait about a minute for the deploy to complete and your new preview should become available at this (secret) URL:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://your-username.github.io/your-repo/index.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will serve the full static contents of your repository so you can also reuse the same private repository for other projects like this in the future, though you'll have to manually select the new branch that Claude creates each time.&lt;/p&gt;
&lt;p&gt;Crucially, your Claude Code for web session is still running. You can request changes from Claude - and even drop in screenshots of what it's built so far - and Claude will make changes and then push them to the existing branch, which means they'll be available in your preview shortly afterwards.&lt;/p&gt;
&lt;p&gt;I've not found a limit on how long these Claude sessions stay available - it's possible you could keep that session and PR running indefinitely, continually pushing changes to your deployed environment for many weeks to come.&lt;/p&gt;
&lt;p&gt;When you land the PR don't forget to update the Pages settings to point back to the main branch.&lt;/p&gt;
&lt;p&gt;Unlike Claude's own Artifacts feature there are no  CSP restrictions on what apps deployed to GitHub Pages can do, which means they can interact directly with JSON APIs hosted on other domains.&lt;/p&gt;
&lt;p&gt;There are other good options for deploying previews of branches - I've used &lt;a href="https://pages.cloudflare.com/" rel="nofollow"&gt;Cloudflare Pages&lt;/a&gt; for this in the past - but it's nice to be able to get this done using GitHub alone.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/claude-code/preview-github-pages"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:neon_neon-1.md</id>
    <title>Taking Neon I at the Crucible</title>
    <updated>2026-01-11T17:34:29+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I took the Neon I intensive week-long evening class at &lt;a href="https://www.thecrucible.org/" rel="nofollow"&gt;the Crucible&lt;/a&gt; in Oakland, with teachers Dan Kuppe and Kat. I learned to make a neon sign! It's still awaiting final infusion of gas, but I'll share photos here once it's finished.&lt;/p&gt;
&lt;p&gt;Here's much of what I learned, at least the parts that can be translated into text.&lt;/p&gt;
&lt;p&gt;At the Neon 1 level, the craft is almost entirely around making shapes out of glass. The starting point is lengths of glass tubes - I think ours were 10mm in diameter and 4 feet long.&lt;/p&gt;
&lt;p&gt;The way you make those shapes is by heating up and bending that glass, using a variety of gas torches.&lt;/p&gt;
&lt;p&gt;This can be done mostly by naked hand - protective gloves weren’t necessary for the bends that we made.&lt;/p&gt;
&lt;p&gt;You hold the glass over the gas flame and rotate it constantly to ensure it is equally warmed. Then after som time - usually 15-30s - the glass becomes malleable enough that you can bend it.&lt;/p&gt;
&lt;p&gt;The key trick to getting the right bend is heating the right section of the glass. For a tight corner you heat just the very short segment that will form the corner. For a long curve you heat the section that will be part of the curve.&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer" href="https://github.com/user-attachments/assets/1ffa89e0-8b85-49c1-8e12-a0ca68fc528e"&gt;&lt;img width="1024" height="768" alt="Our instructor Dan with an elaborate shaped glass tube heating a lengthy portion of it on a long gas burner" src="https://github.com/user-attachments/assets/1ffa89e0-8b85-49c1-8e12-a0ca68fc528e" style="max-width: 100%; height: auto; max-height: 768px;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Bending the glass upwards is more effective than bending it downwards - you can use gravity to help, especially useful for larger curves where the glass will naturally sag once it hits the right temperature.&lt;/p&gt;
&lt;p&gt;For corners you need to ensure the glass both stays sealed but has a hole that the neon can flow through later. You can help achieve this by blowing air into the glass tube, using a stopper at one end and a rubber hose attached to a mouthpiece at the other.&lt;/p&gt;
&lt;p&gt;In a Jedi-make-their-own-lightsabers move, we created our own mouthpieces for the hose by putting a 90 degree bend in a thin tube and then heating and flaring out the end using a torch and a file. I dropped one of these and had to make another one.&lt;/p&gt;
&lt;p&gt;It is crucial you don’t blow into the glass while it is in the flame because it is likely to burst! Instead you blow when the glass has just come out of the flame but is still hot - this can visibly affect the shape of the bend you are making. The goal is to have as close to a round hole in the center of the pipe as possible, although even a misinformed oval will still be OK as long as air (and eventually neon) can flow through it.&lt;/p&gt;
&lt;p&gt;The best way to achieve a specific shape is to draw it precisely on paper, then cover that paper with a wire mesh so you can drop the glass onto it while it’s still malleable and form it to match the illustration. If you forget the mesh (I did that once) your paper will catch fire!&lt;/p&gt;
&lt;p&gt;A tricky skill is planning out the sequence of bends that you will perform on a piece of glass. I haven’t developed a good instinct for this yet. You need to consider the rotation of the glass on the burner - if you create the wrong corners too early you won’t be able to safely position it on the gas such that you can rotate it without burning your hand or getting the glass caught on the apparatus.&lt;/p&gt;
&lt;p&gt;Cutting the glass is quite easy: score one edge with a file and then tap it firmly such that it breaks. Some of my breaks were clean and others were jagged, and I’m not sure why but people with better technique consistently achieved clean breaks.&lt;/p&gt;
&lt;p&gt;By far the hardest technique (for me at least) was &lt;strong&gt;welding&lt;/strong&gt;. This is when you have two glass tubes and you wish to join them together as if they are one - important for combining pieces of the right shape, fixing breaks and attaching the electrodes at the end (more on that below).&lt;/p&gt;
&lt;p&gt;To weld you wedge one glass piece firmly on a table with weights such that the end you attach to protrudes over the edge. Then you align the other glass piece with it and use a hand-held torch to rotate around and heat the ends of the glass tubes - keeping them about half a centimeter away from each other.&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer" href="https://github.com/user-attachments/assets/e3ce2397-6bce-42a8-87d7-0a05e18e3af5"&gt;&lt;img width="1024" height="768" alt="Two glass tubes being heated by a hand torch" src="https://github.com/user-attachments/assets/e3ce2397-6bce-42a8-87d7-0a05e18e3af5" style="max-width: 100%; height: auto; max-height: 768px;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Once they are hot enough you kiss them together, then continue to heat the junction to get it malleable. Then you remove the heat and blow down the tube while stretching it out a little to try to get a clean merged section - ideally looking as close to a regular tube of glass as possible.&lt;/p&gt;
&lt;p&gt;Doing this requires depth perception and a lot of practice. I only have one working eye, which usually doesn’t affect me much but turns out to make welding incredibly hard do to the need to align two glass tubes and a hand torch all at the same time.&lt;/p&gt;
&lt;p&gt;The final step is to attach electrodes to the ends of your glass segments. These come as short glass tubes with wires coming out of them, which you weld onto the ends.&lt;/p&gt;
&lt;p&gt;One of those electrodes will have a thin glass tube protruding from the end. This is used later on to insert the neon (or argon) gas as the final part of the process.&lt;/p&gt;
&lt;p&gt;A few more miscellaneous tips I picked up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The final piece should not have two glass tubes touching each other as this can cause arcs to interfere with each other. A thin layer of silicon between the tubes fixes this.&lt;/li&gt;
&lt;li&gt;Neon comes out orange, argon comes out purple. There are colored glasses that can achieve other colors but we didn’t use these in our beginners’ class.&lt;/li&gt;
&lt;li&gt;It’s important to wrap your rubber hose around your arm in a way that avoids it coming into contact with the burner or hot glass.&lt;/li&gt;
&lt;li&gt;Use wooden paddles to help flatten glass shapes while they are still warm. Using cold metal can cause the glass to weaken or fracture.&lt;/li&gt;
&lt;/ul&gt;
</content>
    <link href="https://til.simonwillison.net/neon/neon-1"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:github_software-archive-recovery.md</id>
    <title>Downloading archived Git repositories from archive.softwareheritage.org</title>
    <updated>2025-12-30T23:43:48+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;Last February I &lt;a href="https://simonwillison.net/2025/Feb/7/sqlite-s3vfs/" rel="nofollow"&gt;blogged about&lt;/a&gt; a neat script called &lt;code&gt;sqlite-s3vfs&lt;/code&gt; which was released as MIT licensed open source by the UK government's Department for Business and Trade.&lt;/p&gt;
&lt;p&gt;I went looking for it today and found that the &lt;a href="https://github.com/uktrade/sqlite-s3vfs"&gt;github.com/uktrade/sqlite-s3vfs&lt;/a&gt; repository is now a 404.&lt;/p&gt;
&lt;p&gt;Since this is taxpayer-funded software and was released MIT I felt a moral obligation to try and restore the repository!&lt;/p&gt;
&lt;p&gt;My restored copy can be found at &lt;a href="https://github.com/simonw/sqlite-s3vfs"&gt;github.com/simonw/sqlite-s3vfs&lt;/a&gt;. Here's how I made it.&lt;/p&gt;
&lt;p&gt;Claude suggested trying the &lt;a href="https://archive.softwareheritage.org/" rel="nofollow"&gt;Software Heritage archive&lt;/a&gt; and sure enough a search for &lt;code&gt;https://github.com/uktrade/sqlite-s3vfs&lt;/code&gt; on their site resolved to &lt;a href="https://archive.softwareheritage.org/browse/origin/directory/?origin_url=https://github.com/uktrade/sqlite-s3vfs" rel="nofollow"&gt;this archived page&lt;/a&gt;, which appeared to have a full copy of the repo.&lt;/p&gt;
&lt;p&gt;Downloading a snapshot of the most recent state was easy enough, but I wanted the full Git history. I stumbled across &lt;a href="https://news.ycombinator.com/item?id=37516523#37517378" rel="nofollow"&gt;this Hacker News comment&lt;/a&gt; which helped me figure out the right way to do this.&lt;/p&gt;
&lt;p&gt;Software Heritage have all sorts of different IDs. The ID that worked for me was something they call a "snapshot ID". I found this in their "Permalinks" sidebar under the "snapshot" tag:&lt;/p&gt;
&lt;p&gt;&lt;a target="_blank" rel="noopener noreferrer" href="https://github.com/user-attachments/assets/8fa16a43-d52e-4fc7-8d94-0a291e7bd04b"&gt;&lt;img src="https://github.com/user-attachments/assets/8fa16a43-d52e-4fc7-8d94-0a291e7bd04b" alt="Screenshot of Software Heritage archive page for https://github.com/uktrade/sqlite-s3vfs showing visit type: git, dated 12 April 2025, 15:35:23 UTC. Navigation tabs include Code, Branches (38), Releases (0), and Visits. Branch: HEAD is selected with commit 8e0f4b6. A red &amp;quot;Permalinks&amp;quot; sidebar is expanded showing instructions: &amp;quot;To reference or cite the objects present in the Software Heritage archive, permalinks based on SoftWare Hash IDentifiers (SWHIDs) must be used. Select below a type of object currently browsed in order to display its associated SWHID and permalink.&amp;quot; Three options shown: directory, revision, and snapshot (selected). The SWHID displayed is &amp;quot;swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf;origin=https://github.com/uktrade/sqlite-s3vfs&amp;quot; with a checked &amp;quot;Add contextual information&amp;quot; checkbox and buttons for &amp;quot;Copy identifier&amp;quot; and &amp;quot;Copy permalink&amp;quot;." style="max-width: 100%;"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It started &lt;code&gt;swh:1:snp:&lt;/code&gt; - the ID for this repository was:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can then make an API call to request a Git bundle for that snapshot:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -XPOST &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://archive.softwareheritage.org/api/1/vault/git-bare/swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This returned JSON that looks like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;{
  &lt;span class="pl-ent"&gt;"fetch_url"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://archive.softwareheritage.org/api/1/vault/git-bare/swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf/raw/&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"progress_message"&lt;/span&gt;: &lt;span class="pl-c1"&gt;null&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"id"&lt;/span&gt;: &lt;span class="pl-c1"&gt;417949633&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"status"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;done&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
  &lt;span class="pl-ent"&gt;"swhid"&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
}&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;That &lt;code&gt;fetch_url&lt;/code&gt; is what you need to download the Git bundle as a &lt;code&gt;.tar.gz&lt;/code&gt; file. It redirects to blob storage so you need to use &lt;code&gt;-L&lt;/code&gt; with &lt;code&gt;curl&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;curl -L -o bundle.tar.gz &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;https://archive.softwareheritage.org/api/1/vault/git-bare/swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf/raw/&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then decompress it:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;tar -xzvf bundle.tar.gz&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The result starts like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/HEAD
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/branches/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/config
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/description
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/hooks/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/info/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/info/exclude
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/info/refs
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/info/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/info/packs
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/pack/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/pack/pack-9946e5e52f40fd1df3352da074f9ac059e87ca9d.idx
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/objects/pack/pack-9946e5e52f40fd1df3352da074f9ac059e87ca9d.pack
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/dot-github/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/dot-github/workflows/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/dot-github/workflows/actions/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/dependabot/github_actions/dot-github/workflows/actions/download-artifact-4.1.7
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/heads/main
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/pull/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/pull/11/
x swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git/refs/pull/11/head
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git&lt;/code&gt; is a bare Git repository. You can clone it into a usable working copy like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;git clone swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git sqlite-s3vfs&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I created an empty repo on GitHub and ran these commands to push everything up:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;&lt;span class="pl-c1"&gt;cd&lt;/span&gt; sqlite-s3vfs
git remote set-url origin git@github.com:simonw/sqlite-s3vfs.git
git push --all origin
git push --tags origin&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I had to use &lt;code&gt;set-url&lt;/code&gt; because the original origin was that &lt;code&gt;/tmp/swh:1:snp:1930ecd7bcc8c8666c721c4def3944c98d650abf.git&lt;/code&gt; local path.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Building an HTML tool to make this easier in the future&lt;/h2&gt;&lt;a id="user-content-building-an-html-tool-to-make-this-easier-in-the-future" class="anchor" aria-label="Permalink: Building an HTML tool to make this easier in the future" href="#building-an-html-tool-to-make-this-easier-in-the-future"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;It turns out all of the relevant APIs are unauthenticated and support CORS headers, so I had Claude Code build me a page for automating part of this process in the future:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/software-heritage-repo" rel="nofollow"&gt;https://tools.simonwillison.net/software-heritage-repo&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here's that tool with the old GitHub repo URL pre-filled, just click the button:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://tools.simonwillison.net/software-heritage-repo#https%3A%2F%2Fgithub.com%2Fuktrade%2Fsqlite-s3vfs" rel="nofollow"&gt;https://tools.simonwillison.net/software-heritage-repo#https%3A%2F%2Fgithub.com%2Fuktrade%2Fsqlite-s3vfs&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;And here's &lt;a href="https://gistpreview.github.io/?3a76a868095c989d159c226b7622b092/index.html" rel="nofollow"&gt;the Claude Code transcript&lt;/a&gt; I used to build this.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/github/software-archive-recovery"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:pytest_subtests.md</id>
    <title>Subtests in pytest 9.0.0+</title>
    <updated>2025-12-05T05:44:04+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;&lt;a href="https://pypi.org/project/pytest/9.0.0/" rel="nofollow"&gt;pytest 9.0.0&lt;/a&gt; was released on November 8th 2025. I just got around to looking at the &lt;a href="https://docs.pytest.org/en/stable/changelog.html#pytest-9-0-0-2025-11-05" rel="nofollow"&gt;release notes&lt;/a&gt; and the biggest new feature is &lt;strong&gt;subtests&lt;/strong&gt;, previously available as the separate &lt;a href="https://pypi.org/project/pytest-subtests/" rel="nofollow"&gt;pytest-subtests&lt;/a&gt; plugin.&lt;/p&gt;
&lt;p&gt;I copied the documentation into Claude Code and &lt;a href="https://gistpreview.github.io/?0487e5bb12bcbed850790a6324788e1b" rel="nofollow"&gt;told it to find a good place to try them&lt;/a&gt; in the Datasette test suite. It suggested &lt;a href="https://github.com/simonw/datasette/blob/1.0a23/tests/test_docs.py"&gt;tests/test_docs.py&lt;/a&gt;, which currently make heavy use of the &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; mechanism. I wrote about how that works a few years ago in &lt;a href="https://simonwillison.net/2018/Jul/28/documentation-unit-tests/" rel="nofollow"&gt;Documentation unit tests&lt;/a&gt; - here's an example test:&lt;/p&gt;
&lt;div class="highlight highlight-source-python"&gt;&lt;pre&gt;&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;pytest&lt;/span&gt;.&lt;span class="pl-c1"&gt;mark&lt;/span&gt;.&lt;span class="pl-c1"&gt;parametrize&lt;/span&gt;(&lt;span class="pl-s"&gt;"setting"&lt;/span&gt;, &lt;span class="pl-s1"&gt;app&lt;/span&gt;.&lt;span class="pl-c1"&gt;SETTINGS&lt;/span&gt;)&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;test_settings_are_documented&lt;/span&gt;(&lt;span class="pl-s1"&gt;settings_headings&lt;/span&gt;, &lt;span class="pl-s1"&gt;setting&lt;/span&gt;):
    &lt;span class="pl-k"&gt;assert&lt;/span&gt; &lt;span class="pl-s1"&gt;setting&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;settings_headings&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Understanding subtests&lt;/h2&gt;&lt;a id="user-content-understanding-subtests" class="anchor" aria-label="Permalink: Understanding subtests" href="#understanding-subtests"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The idea behind subtests is to allow a test to programatically create new subtest within itself at runtime.&lt;/p&gt;
&lt;p&gt;My above example does the same thing using &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; - but it relies on the list of settings being known at test collection time. This might not be possible for things that need to be introspected after the test has run some initial setup code.&lt;/p&gt;
&lt;p&gt;Here's the above test ported to use subtests instead:&lt;/p&gt;
&lt;div class="highlight highlight-source-python"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;test_settings_are_documented&lt;/span&gt;(&lt;span class="pl-s1"&gt;settings_headings&lt;/span&gt;, &lt;span class="pl-s1"&gt;subtests&lt;/span&gt;):
    &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;setting&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;app&lt;/span&gt;.&lt;span class="pl-c1"&gt;SETTINGS&lt;/span&gt;:
        &lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-s1"&gt;subtests&lt;/span&gt;.&lt;span class="pl-c1"&gt;test&lt;/span&gt;(&lt;span class="pl-s1"&gt;setting&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;setting&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&lt;/span&gt;):
            &lt;span class="pl-k"&gt;assert&lt;/span&gt; &lt;span class="pl-s1"&gt;setting&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;settings_headings&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;subtests&lt;/code&gt; is a new default pytest fixture - if you list that as a parameter to your test function you can use it in the body of the test.&lt;/p&gt;
&lt;p&gt;Using &lt;code&gt;with subtests.test(...)&lt;/code&gt; creates a new subtest. Here I'm doing that in a loop. The keyword arguments passed to &lt;code&gt;subtests.test()&lt;/code&gt; are used to identify the subtest in the test report.&lt;/p&gt;
&lt;p&gt;That's all it takes! Here's &lt;a href="https://github.com/simonw/datasette/commit/1d4448fc5603f479f11b37b9da0ee11c2b1a19e4"&gt;a commit&lt;/a&gt; that ported several of my parameterized tests to use subtests instead.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;How subtests differ from parametrize&lt;/h2&gt;&lt;a id="user-content-how-subtests-differ-from-parametrize" class="anchor" aria-label="Permalink: How subtests differ from parametrize" href="#how-subtests-differ-from-parametrize"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;If you use &lt;code&gt;@pytest.mark.parametrize&lt;/code&gt; pytest will behave as if every one of your parameter combinations is a separate test function. Running the old &lt;code&gt;pytest tests/test_docs.py&lt;/code&gt; tests looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.0.1, pluggy-1.6.0
SQLite: 3.50.4
rootdir: /private/tmp/datasette
configfile: pytest.ini
plugins: anyio-4.12.0, xdist-3.8.0, timeout-2.4.0, asyncio-1.3.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 120 items                                                            

tests/test_docs.py ..................................................... [ 44%]
...................................................................      [100%]

============================= 120 passed in 0.84s ==============================
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Porting to subtests causes each test to be reported just once:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;...
collected 9 items                                                             

tests/test_docs.py .........                                            [100%]

============================== 9 passed in 0.15s ==============================
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But... if you add &lt;code&gt;-v&lt;/code&gt; for verbose output you get back a report that &lt;em&gt;does&lt;/em&gt; include every subtest. Truncated, that looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;...
collected 9 items                                                                                                

tests/test_docs.py::test_settings_are_documented SUBPASSED(setting='default_page_size')                    [ 11%]
tests/test_docs.py::test_settings_are_documented SUBPASSED(setting='max_returned_rows')                    [ 11%]
...
tests/test_docs.py::test_settings_are_documented PASSED                                                    [ 11%]
tests/test_docs.py::test_plugin_hooks_are_documented SUBPASSED(plugin='actor_from_request')                [ 22%]
tests/test_docs.py::test_plugin_hooks_are_documented SUBPASSED(plugin='actors_from_ids')                   [ 22%]
tests/test_docs.py::test_plugin_hooks_are_documented PASSED                                                [ 22%]...

...
tests/test_docs.py::test_rst_heading_underlines_match_title_length PASSED                                  [ 66%]
tests/test_docs.py::test_homepage PASSED                                                                   [ 77%]
tests/test_docs.py::test_actor_is_null PASSED                                                              [ 88%]
tests/test_docs.py::test_signed_cookie_actor PASSED                                                        [100%]

============================== 9 passed, 116 subtests passed in 0.15s ==============================
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The last line shows how many subtests passed in addition to how many tests.&lt;/p&gt;
&lt;p&gt;It looks to me like subtests run substantially faster than the eqpuivalent parameterized tests. I'm more interested in the fact that subtests can now be programatically generated at runtime based on test setup code.&lt;/p&gt;
</content>
    <link href="https://til.simonwillison.net/pytest/subtests"/>
  </entry>
  <entry>
    <id>tag:til.simonwillison.net,2020-04-30:uv_dependency-groups.md</id>
    <title>Dependency groups and uv run</title>
    <updated>2025-12-03T04:55:17+00:00</updated>
    <author>
      <name>Simon Willison</name>
      <uri>https://simonwillison.net/</uri>
    </author>
    <content type="html">&lt;p&gt;I've adopted a new (to me) pattern for my Python projects to make them easier to hack on using &lt;code&gt;uv run&lt;/code&gt;. I'm using a &lt;a href="https://peps.python.org/pep-0735/" rel="nofollow"&gt;PEP 735 dependency group&lt;/a&gt; called &lt;code&gt;dev&lt;/code&gt; to declare my development dependencies - in particular &lt;code&gt;pytest&lt;/code&gt; - such that running &lt;code&gt;uv run pytest&lt;/code&gt; executes the tests for my project without me having to even think about setting up a virtual environment first.&lt;/p&gt;
&lt;p&gt;Here's the pattern I'm using. I start by creating a new library using &lt;code&gt;uv init --lib&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;mkdir my-new-library
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; my-new-library
uv init --lib&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This creates a &lt;code&gt;pyproject.toml&lt;/code&gt; file like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;project&lt;/span&gt;]
&lt;span class="pl-smi"&gt;name&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;my-new-library&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;version&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;0.1.0&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;description&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Add your description here&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;readme&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;README.md&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;authors&lt;/span&gt; = [
    { &lt;span class="pl-smi"&gt;name&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Simon Willison&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;, &lt;span class="pl-smi"&gt;email&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;...@gmail.com&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; }
]
&lt;span class="pl-smi"&gt;requires-python&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&amp;gt;=3.10&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-smi"&gt;dependencies&lt;/span&gt; = []

[&lt;span class="pl-en"&gt;build-system&lt;/span&gt;]
&lt;span class="pl-smi"&gt;requires&lt;/span&gt; = [&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;uv_build&amp;gt;=0.9.15,&amp;lt;0.10.0&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;]
&lt;span class="pl-smi"&gt;build-backend&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;uv_build&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;It also creates a &lt;code&gt;src/my_new_library/__init__.py&lt;/code&gt; file with a &lt;code&gt;hello()&lt;/code&gt; example function.&lt;/p&gt;
&lt;p&gt;Next, I add &lt;code&gt;pytest&lt;/code&gt; as a development dependency using this command:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv add --dev pytest&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Doing so adds a new section to the end of that &lt;code&gt;pyproject.toml&lt;/code&gt; file:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;dependency-groups&lt;/span&gt;]
&lt;span class="pl-smi"&gt;dev&lt;/span&gt; = [
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;pytest&amp;gt;=9.0.1&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
]&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This also creates the virtual environment in &lt;code&gt;.venv&lt;/code&gt; and &lt;code&gt;uv.lock&lt;/code&gt; file, but we don't need to think about those.&lt;/p&gt;
&lt;p&gt;Then I create a test:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;mkdir tests
&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;from my_new_library import hello&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;def test_hello():&lt;/span&gt;
&lt;span class="pl-s"&gt;    assert hello() == "Hello from my-new-library!"&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;&amp;gt;&lt;/span&gt; tests/test_my_new_library.py&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Now I can run that test suite using:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run pytest&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;dev&lt;/code&gt; dependency group is a special case for &lt;code&gt;uv run&lt;/code&gt; - it will always install those dependencies as well such that commands like &lt;code&gt;pytest&lt;/code&gt; can work correctly.&lt;/p&gt;
&lt;p&gt;When you package a project for distribution the &lt;code&gt;dev&lt;/code&gt; dependencies will not be automatically installed for users of your package.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;The importance of [build-system] for specifying a package&lt;/h2&gt;&lt;a id="user-content-the-importance-of-build-system-for-specifying-a-package" class="anchor" aria-label="Permalink: The importance of [build-system] for specifying a package" href="#the-importance-of-build-system-for-specifying-a-package"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;That &lt;code&gt;[build-system]&lt;/code&gt; section is crucial, because it tells &lt;code&gt;uv&lt;/code&gt; that the directory should be treated as a "package". This means that when &lt;code&gt;uv run&lt;/code&gt; executes it installs the current directory as an editable package in the virtual environment.&lt;/p&gt;
&lt;p&gt;Removing &lt;code&gt;[build-system]&lt;/code&gt; and then &lt;code&gt;rm -rf .venv&lt;/code&gt; to delete the virtual environment results in the following error when trying to run &lt;code&gt;uv run pytest&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;________________ ERROR collecting tests/test_my_new_library.py _________________
ImportError while importing test module '/private/tmp/my-new-library/tests/test_my_new_library.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/opt/homebrew/Caskroom/miniconda/base/lib/python3.10/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
tests/test_my_new_library.py:1: in &amp;lt;module&amp;gt;
    from my_new_library import hello
E   ModuleNotFoundError: No module named 'my_new_library'
=========================== short test summary info ============================
ERROR tests/test_my_new_library.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.07s ===============================
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Adding the &lt;code&gt;[build-system]&lt;/code&gt; section back in gets rid of this error.&lt;/p&gt;
&lt;p&gt;There's an alternative to having a &lt;code&gt;[build-system]&lt;/code&gt; section, which is to add this to the &lt;code&gt;pyproject.toml&lt;/code&gt; file instead:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;tool&lt;/span&gt;.&lt;span class="pl-en"&gt;uv&lt;/span&gt;]
&lt;span class="pl-smi"&gt;package&lt;/span&gt; = &lt;span class="pl-c1"&gt;true&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;I &lt;a href="https://gistpreview.github.io/?e5405e5770d963ee708eea5ecc769457" rel="nofollow"&gt;had Claude Code investigate this&lt;/a&gt; and it found &lt;a href="https://github.com/astral-sh/uv/blob/d2db06983a20630de4247e4a94af7edd5aa35689/crates/uv-workspace/src/pyproject.rs#L135-L143"&gt;this section of code&lt;/a&gt; explaining what's going on:&lt;/p&gt;
&lt;div class="highlight highlight-source-rust"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;pub&lt;/span&gt; &lt;span class="pl-k"&gt;fn&lt;/span&gt; &lt;span class="pl-en"&gt;is_package&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-c1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="pl-smi"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s1"&gt;require_build_system&lt;/span&gt;&lt;span class="pl-kos"&gt;:&lt;/span&gt; &lt;span class="pl-smi"&gt;bool&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; -&amp;gt; &lt;span class="pl-smi"&gt;bool&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-c"&gt;// If `tool.uv.package` is set, defer to that explicit setting.&lt;/span&gt;
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-v"&gt;Some&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;is_package&lt;span class="pl-kos"&gt;)&lt;/span&gt; = &lt;span class="pl-smi"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;tool_uv_package&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
        &lt;span class="pl-k"&gt;return&lt;/span&gt; is_package&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;
    &lt;span class="pl-c"&gt;// Otherwise, a project is assumed to be a package if `build-system` is present.&lt;/span&gt;
    &lt;span class="pl-smi"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;build_system&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;is_some&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; || !require_build_system
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;So there's no need to use that &lt;code&gt;[tool.uv]&lt;/code&gt; section if you already have a &lt;code&gt;[build-system]&lt;/code&gt; section.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Installation in CI&lt;/h2&gt;&lt;a id="user-content-installation-in-ci" class="anchor" aria-label="Permalink: Installation in CI" href="#installation-in-ci"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;If you're still using regular &lt;code&gt;pip&lt;/code&gt; in your CI scripts you'll need to ensure the &lt;code&gt;dev&lt;/code&gt; dependency group is installed like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;pip install &lt;span class="pl-c1"&gt;.&lt;/span&gt; --group dev&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;The end result&lt;/h2&gt;&lt;a id="user-content-the-end-result" class="anchor" aria-label="Permalink: The end result" href="#the-end-result"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;Projects that use this pattern become a whole lot easier for other people to hack on. I first used this for my &lt;a href="https://github.com/datasette/datasette-extract"&gt;datasette-extract&lt;/a&gt; package, with the result that checking out and running the tests is now a case of running just the following commands:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;git clone https://github.com/datasette/datasette-extract
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; datasette-extract
uv run pytest&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;No need to think about virtual environments or development dependencies - this just works.&lt;/p&gt;
&lt;p&gt;Building a distributable wheel of the project is a one-liner as well:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv build&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This creates two files:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dist/datasette_extract-0.2a0-py3-none-any.whl
dist/datasette_extract-0.2a0.tar.gz
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;.tar.gz&lt;/code&gt; file should contain everything including the tests - the &lt;code&gt;.whl&lt;/code&gt; file should contain just the non-development Python code.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;&lt;h2 class="heading-element"&gt;Bonus tip: defining dev in terms of other dependency groups&lt;/h2&gt;&lt;a id="user-content-bonus-tip-defining-dev-in-terms-of-other-dependency-groups" class="anchor" aria-label="Permalink: Bonus tip: defining dev in terms of other dependency groups" href="#bonus-tip-defining-dev-in-terms-of-other-dependency-groups"&gt;&lt;span aria-hidden="true" class="octicon octicon-link"&gt;&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;dev&lt;/code&gt; group is a special case: tools like &lt;code&gt;uv run&lt;/code&gt; will install everything in that group automatically.&lt;/p&gt;
&lt;p&gt;What if you want to divide up your dependencies into test dependencies, documentation dependencies and so on?&lt;/p&gt;
&lt;p&gt;Thanks to &lt;a href="https://github.com/mgaitan/python-package-copier-template/blob/676a89117b10f6267cfe45dd1c36efc80af95be7/project/pyproject.toml.jinja#L97-L121"&gt;Martín Gaitán's python-package-copier-template&lt;/a&gt; I learned about this pattern for defining &lt;code&gt;dev&lt;/code&gt; in terms of other groups:&lt;/p&gt;
&lt;div class="highlight highlight-source-toml"&gt;&lt;pre&gt;[&lt;span class="pl-en"&gt;dependency-groups&lt;/span&gt;]
&lt;span class="pl-smi"&gt;test&lt;/span&gt; = [
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;pytest&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;pytest-asyncio&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
]
&lt;span class="pl-smi"&gt;docs&lt;/span&gt; = [
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;sphinx&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
]
&lt;span class="pl-smi"&gt;dev&lt;/span&gt; = [
    { &lt;span class="pl-smi"&gt;include-group&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;test&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; },
    { &lt;span class="pl-smi"&gt;include-group&lt;/span&gt; = &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;docs&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; }
]&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Now if you just want to use the test dependencies you can run this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uv run --group &lt;span class="pl-c1"&gt;test&lt;/span&gt; pytest&lt;/pre&gt;&lt;/div&gt;
</content>
    <link href="https://til.simonwillison.net/uv/dependency-groups"/>
  </entry>
</feed>
