<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: til</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/til.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-02-20T23:47:10+00:00</updated><author><name>Simon Willison</name></author><entry><title>Adding TILs, releases, museums, tools and research to my blog</title><link href="https://simonwillison.net/2026/Feb/20/beats/#atom-tag" rel="alternate"/><published>2026-02-20T23:47:10+00:00</published><updated>2026-02-20T23:47:10+00:00</updated><id>https://simonwillison.net/2026/Feb/20/beats/#atom-tag</id><summary type="html">
    &lt;p&gt;I've been wanting to add indications of my various other online activities to my blog for a while now. I just turned on a new feature I'm calling "beats" (after story beats, naming this was hard!) which adds five new types of content to my site, all corresponding to activity elsewhere.&lt;/p&gt;
&lt;p&gt;Here's what beats look like:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/three-beats.jpg" alt="Screenshot of a fragment of a page showing three entries from 30th Dec 2025. First: [RELEASE] &amp;quot;datasette-turnstile 0.1a0 — Configurable CAPTCHAs for Datasette paths usin…&amp;quot; at 7:23 pm. Second: [TOOL] &amp;quot;Software Heritage Repository Retriever — Download archived Git repositories f…&amp;quot; at 11:41 pm. Third: [TIL] &amp;quot;Downloading archived Git repositories from archive.softwareheritage.org — …&amp;quot; at 11:43 pm." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Those three are from &lt;a href="https://simonwillison.net/2025/Dec/30/"&gt;the 30th December 2025&lt;/a&gt; archive page.&lt;/p&gt;
&lt;p&gt;Beats are little inline links with badges that fit into different content timeline views around my site, including the homepage, search and archive pages.&lt;/p&gt;
&lt;p&gt;There are currently five types of beats:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/elsewhere/release/"&gt;Releases&lt;/a&gt; are GitHub releases of my many different open source projects, imported from &lt;a href="https://github.com/simonw/simonw/blob/main/releases_cache.json"&gt;this JSON file&lt;/a&gt; that was constructed &lt;a href="https://simonwillison.net/2020/Jul/10/self-updating-profile-readme/"&gt;by GitHub Actions&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/elsewhere/til/"&gt;TILs&lt;/a&gt; are the posts from my &lt;a href="https://til.simonwillison.net/"&gt;TIL blog&lt;/a&gt;, imported using &lt;a href="https://github.com/simonw/simonwillisonblog/blob/f883b92be23892d082de39dbada571e406f5cfbf/blog/views.py#L1169"&gt;a SQL query over JSON and HTTP&lt;/a&gt; against the Datasette instance powering that site.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/elsewhere/museum/"&gt;Museums&lt;/a&gt; are new posts on my &lt;a href="https://www.niche-museums.com/"&gt;niche-museums.com&lt;/a&gt; blog, imported from &lt;a href="https://github.com/simonw/museums/blob/909bef71cc8d336bf4ac1f13574db67a6e1b3166/plugins/export.py"&gt;this custom JSON feed&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/elsewhere/tool/"&gt;Tools&lt;/a&gt; are HTML and JavaScript tools I've vibe-coded on my &lt;a href="https://tools.simonwillison.net/"&gt;tools.simonwillison.net&lt;/a&gt; site, as described in &lt;a href="https://simonwillison.net/2025/Dec/10/html-tools/"&gt;Useful patterns for building HTML tools&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonwillison.net/elsewhere/research/"&gt;Research&lt;/a&gt; is for AI-generated research projects, hosted in my &lt;a href="https://github.com/simonw/research"&gt;simonw/research repo&lt;/a&gt; and described in &lt;a href="https://simonwillison.net/2025/Nov/6/async-code-research/"&gt;Code research projects with async coding agents like Claude Code and Codex&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's five different custom integrations to pull in all of that data. The good news is that this kind of integration project is the kind of thing that coding agents &lt;em&gt;really&lt;/em&gt; excel at. I knocked most of the feature out in a single morning while working in parallel on various other things.&lt;/p&gt;
&lt;p&gt;I didn't have a useful structured feed of my Research projects, and it didn't matter because I gave Claude Code a link to &lt;a href="https://raw.githubusercontent.com/simonw/research/refs/heads/main/README.md"&gt;the raw Markdown README&lt;/a&gt; that lists them all and it &lt;a href="https://github.com/simonw/simonwillisonblog/blob/f883b92be23892d082de39dbada571e406f5cfbf/blog/importers.py#L77-L80"&gt;spun up a parser regex&lt;/a&gt;. Since I'm responsible for both the source and the destination I'm fine with a brittle solution that would be too risky against a source that I don't control myself.&lt;/p&gt;
&lt;p&gt;Claude also handled all of the potentially tedious UI integration work with my site, making sure the new content worked on all of my different page types and was handled correctly by my &lt;a href="https://simonwillison.net/2017/Oct/5/django-postgresql-faceted-search/"&gt;faceted search engine&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="prototyping-with-claude-artifacts"&gt;Prototyping with Claude Artifacts&lt;/h4&gt;
&lt;p&gt;I actually prototyped the initial concept for beats in regular Claude - not Claude Code - taking advantage of the fact that it can clone public repos from GitHub these days. I started with:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone simonw/simonwillisonblog and tell me about the models and views&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And then later in the brainstorming session said:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;use the templates and CSS in this repo to create a new artifact with all HTML and CSS inline that shows me my homepage with some of those inline content types mixed in&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;After some iteration we got to &lt;a href="https://gisthost.github.io/?c3f443cc4451cf8ce03a2715a43581a4/preview.html"&gt;this artifact mockup&lt;/a&gt;, which was enough to convince me that the concept had legs and was worth handing over to full &lt;a href="https://code.claude.com/docs/en/claude-code-on-the-web"&gt;Claude Code for web&lt;/a&gt; to implement.&lt;/p&gt;
&lt;p&gt;If you want to see how the rest of the build played out the most interesting PRs are &lt;a href="https://github.com/simonw/simonwillisonblog/pull/592"&gt;Beats #592&lt;/a&gt; which implemented the core feature and &lt;a href="https://github.com/simonw/simonwillisonblog/pull/595/changes"&gt;Add Museums Beat importer #595&lt;/a&gt; which added the Museums content type.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/museums"&gt;museums&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-artifacts"&gt;claude-artifacts&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/site-upgrades"&gt;site-upgrades&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="blogging"/><category term="museums"/><category term="ai"/><category term="til"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude-artifacts"/><category term="claude-code"/><category term="site-upgrades"/></entry><entry><title>TIL: Running OpenClaw in Docker</title><link href="https://simonwillison.net/2026/Feb/1/openclaw-in-docker/#atom-tag" rel="alternate"/><published>2026-02-01T23:59:13+00:00</published><updated>2026-02-01T23:59:13+00:00</updated><id>https://simonwillison.net/2026/Feb/1/openclaw-in-docker/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/llms/openclaw-docker"&gt;TIL: Running OpenClaw in Docker&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I've been running &lt;a href="https://openclaw.ai/"&gt;OpenClaw&lt;/a&gt; using Docker on my Mac. Here are the first in my ongoing notes on how I set that up and the commands I'm using to administer it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/llms/openclaw-docker#use-their-docker-compose-configuration"&gt;Use their Docker Compose configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/llms/openclaw-docker#answering-all-of-those-questions"&gt;Answering all of those questions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/llms/openclaw-docker#running-administrative-commands"&gt;Running administrative commands&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/llms/openclaw-docker#setting-up-a-telegram-bot"&gt;Setting up a Telegram bot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/llms/openclaw-docker#accessing-the-web-ui"&gt;Accessing the web UI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/llms/openclaw-docker#running-commands-as-root"&gt;Running commands as root&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here's a screenshot of the web UI that this serves on localhost:&lt;/p&gt;
&lt;p&gt;&lt;img 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." src="https://static.simonwillison.net/static/2026/openclaw-web-ui.jpg" /&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/docker"&gt;docker&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-agents"&gt;ai-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openclaw"&gt;openclaw&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai"/><category term="docker"/><category term="til"/><category term="generative-ai"/><category term="llms"/><category term="ai-agents"/><category term="openclaw"/></entry><entry><title>TIL from taking Neon I at the Crucible</title><link href="https://simonwillison.net/2026/Jan/11/neon-i-at-the-crucible/#atom-tag" rel="alternate"/><published>2026-01-11T17:35:57+00:00</published><updated>2026-01-11T17:35:57+00:00</updated><id>https://simonwillison.net/2026/Jan/11/neon-i-at-the-crucible/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/neon/neon-1"&gt;TIL from taking Neon I at the Crucible&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Things I learned about making neon signs after a week long intensive evening class at &lt;a href="https://www.thecrucible.org/"&gt;the Crucible&lt;/a&gt; in Oakland.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/art"&gt;art&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;



</summary><category term="art"/><category term="til"/></entry><entry><title>TIL: Downloading archived Git repositories from archive.softwareheritage.org</title><link href="https://simonwillison.net/2025/Dec/30/software-heritage/#atom-tag" rel="alternate"/><published>2025-12-30T23:51:33+00:00</published><updated>2025-12-30T23:51:33+00:00</updated><id>https://simonwillison.net/2025/Dec/30/software-heritage/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/github/software-archive-recovery"&gt;TIL: Downloading archived Git repositories from archive.softwareheritage.org&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Back in February I &lt;a href="https://simonwillison.net/2025/Feb/7/sqlite-s3vfs/"&gt;blogged about&lt;/a&gt; a neat Python library called &lt;code&gt;sqlite-s3vfs&lt;/code&gt; for accessing SQLite databases hosted in an S3 bucket, 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 open source software I saw it as my moral duty to try and restore access! It turns out &lt;a href="https://archive.softwareheritage.org/browse/origin/directory/?origin_url=https://github.com/uktrade/sqlite-s3vfs"&gt;a full copy&lt;/a&gt; had been captured by &lt;a href="https://archive.softwareheritage.org/"&gt;the Software Heritage archive&lt;/a&gt;, so I was able to restore  the repository from there. My copy is now archived at &lt;a href="https://github.com/simonw/sqlite-s3vfs"&gt;simonw/sqlite-s3vfs&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The process for retrieving an archive was non-obvious, so I've written up a TIL and also published a new &lt;a href="https://tools.simonwillison.net/software-heritage-repo#https%3A%2F%2Fgithub.com%2Fuktrade%2Fsqlite-s3vfs"&gt;Software Heritage Repository Retriever&lt;/a&gt; tool which takes advantage of the CORS-enabled APIs provided by Software Heritage. Here's &lt;a href="https://gistpreview.github.io/?3a76a868095c989d159c226b7622b092/index.html"&gt;the Claude Code transcript&lt;/a&gt; from building that.

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


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/archives"&gt;archives&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git"&gt;git&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="archives"/><category term="git"/><category term="github"/><category term="open-source"/><category term="tools"/><category term="ai"/><category term="til"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="claude-code"/></entry><entry><title>TIL: Subtests in pytest 9.0.0+</title><link href="https://simonwillison.net/2025/Dec/5/til-pytest-subtests/#atom-tag" rel="alternate"/><published>2025-12-05T06:03:29+00:00</published><updated>2025-12-05T06:03:29+00:00</updated><id>https://simonwillison.net/2025/Dec/5/til-pytest-subtests/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/pytest/subtests"&gt;TIL: Subtests in pytest 9.0.0+&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I spotted an interesting new feature &lt;a href="https://docs.pytest.org/en/stable/changelog.html#pytest-9-0-0-2025-11-05"&gt;in the release notes for pytest 9.0.0&lt;/a&gt;: &lt;a href="https://docs.pytest.org/en/stable/how-to/subtests.html#subtests"&gt;subtests&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I'm a &lt;em&gt;big&lt;/em&gt; user of the &lt;a href="https://docs.pytest.org/en/stable/example/parametrize.html"&gt;pytest.mark.parametrize&lt;/a&gt; decorator - see &lt;a href="https://simonwillison.net/2018/Jul/28/documentation-unit-tests/"&gt;Documentation unit tests&lt;/a&gt; from 2018 - so I thought it would be interesting to try out subtests and see if they're a useful alternative.&lt;/p&gt;
&lt;p&gt;Short version: this parameterized test:&lt;/p&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;p&gt;Becomes this using subtests instead:&lt;/p&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;p&gt;Why is this better? Two reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It appears to run a bit faster&lt;/li&gt;
&lt;li&gt;Subtests can be created programatically after running some setup code first&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I &lt;a href="https://gistpreview.github.io/?0487e5bb12bcbed850790a6324788e1b"&gt;had Claude Code&lt;/a&gt; port &lt;a href="https://github.com/simonw/datasette/pull/2609/files"&gt;several tests&lt;/a&gt; to the new pattern. I like it.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pytest"&gt;pytest&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="testing"/><category term="ai"/><category term="pytest"/><category term="til"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="coding-agents"/><category term="claude-code"/></entry><entry><title>TIL: Dependency groups and uv run</title><link href="https://simonwillison.net/2025/Dec/3/til-dependency-groups-and-uv-run/#atom-tag" rel="alternate"/><published>2025-12-03T05:55:23+00:00</published><updated>2025-12-03T05:55:23+00:00</updated><id>https://simonwillison.net/2025/Dec/3/til-dependency-groups-and-uv-run/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/uv/dependency-groups"&gt;TIL: Dependency groups and uv run&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I wrote up the new pattern I'm using for my various Python project repos to make them as easy to hack on with &lt;code&gt;uv&lt;/code&gt; as possible. The trick is to use a &lt;a href="https://peps.python.org/pep-0735/"&gt;PEP 735 dependency group&lt;/a&gt; called &lt;code&gt;dev&lt;/code&gt;, declared in &lt;code&gt;pyproject.toml&lt;/code&gt; like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[dependency-groups]
dev = ["pytest"]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With that in place, running &lt;code&gt;uv run pytest&lt;/code&gt; will automatically install that development dependency into a new virtual environment and use it to run your tests.&lt;/p&gt;
&lt;p&gt;This means you can get started hacking on one of my projects (here &lt;a href="https://github.com/datasette/datasette-extract"&gt;datasette-extract&lt;/a&gt;) with just these steps:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/datasette/datasette-extract
cd datasette-extract
uv run pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also split my &lt;a href="https://til.simonwillison.net/uv"&gt;uv TILs out&lt;/a&gt; into a separate folder. This meant I had to setup redirects for the old paths, so I had &lt;a href="https://gistpreview.github.io/?f460e64d1768b418b594614f9f57eb89"&gt;Claude Code help build me&lt;/a&gt; a new plugin called &lt;a href="https://github.com/datasette/datasette-redirects"&gt;datasette-redirects&lt;/a&gt; and then &lt;a href="https://github.com/simonw/til/commit/5191fb1f98f19e6788b8e7249da6f366e2f47343"&gt;apply it to my TIL site&lt;/a&gt;, including &lt;a href="https://gistpreview.github.io/?d78470bc652dc257b06474edf3dea61c"&gt;updating the build script&lt;/a&gt; to correctly track the creation date of files that had since been renamed.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/packaging"&gt;packaging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="packaging"/><category term="python"/><category term="ai"/><category term="til"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="uv"/><category term="coding-agents"/><category term="claude-code"/></entry><entry><title>Using Codex CLI with gpt-oss:120b on an NVIDIA DGX Spark via Tailscale</title><link href="https://simonwillison.net/2025/Nov/7/codex-tailscale-spark/#atom-tag" rel="alternate"/><published>2025-11-07T07:23:12+00:00</published><updated>2025-11-07T07:23:12+00:00</updated><id>https://simonwillison.net/2025/Nov/7/codex-tailscale-spark/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/llms/codex-spark-gpt-oss"&gt;Using Codex CLI with gpt-oss:120b on an NVIDIA DGX Spark via Tailscale&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Inspired by a &lt;a href="https://www.youtube.com/watch?v=qy4ci7AoF9Y&amp;amp;lc=UgzaGdLX8TAuQ9ugx1Z4AaABAg"&gt;YouTube comment&lt;/a&gt; I wrote up how I run OpenAI's Codex CLI coding agent against the gpt-oss:120b model running in Ollama on my &lt;a href="https://simonwillison.net/2025/Oct/14/nvidia-dgx-spark/"&gt;NVIDIA DGX Spark&lt;/a&gt; via a Tailscale network.&lt;/p&gt;
&lt;p&gt;It takes a little bit of work to configure but the result is I can now use Codex CLI on my laptop anywhere in the world against a self-hosted model.&lt;/p&gt;
&lt;p&gt;I used it to build &lt;a href="https://static.simonwillison.net/static/2025/gpt-oss-120b-invaders.html"&gt;this space invaders clone&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tailscale"&gt;tailscale&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/local-llms"&gt;local-llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/nvidia"&gt;nvidia&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/space-invaders"&gt;space-invaders&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex-cli"&gt;codex-cli&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/nvidia-spark"&gt;nvidia-spark&lt;/a&gt;&lt;/p&gt;



</summary><category term="ai"/><category term="tailscale"/><category term="til"/><category term="generative-ai"/><category term="local-llms"/><category term="llms"/><category term="nvidia"/><category term="coding-agents"/><category term="space-invaders"/><category term="codex-cli"/><category term="nvidia-spark"/></entry><entry><title>Curiosity-driven blogging</title><link href="https://simonwillison.net/2025/Oct/31/curiosity-driven/#atom-tag" rel="alternate"/><published>2025-10-31T17:09:56+00:00</published><updated>2025-10-31T17:09:56+00:00</updated><id>https://simonwillison.net/2025/Oct/31/curiosity-driven/#atom-tag</id><summary type="html">
    &lt;p&gt;My piece this morning &lt;a href="https://simonwillison.net/2025/Oct/31/coreweave-acquires-marimo/"&gt;about the Marimo acquisition&lt;/a&gt; is an example of a variant of a &lt;a href="https://til.simonwillison.net"&gt;TIL&lt;/a&gt; - I didn't know much about CoreWeave, the acquiring company, so I poked around to answer my own questions and then wrote up what I learned as a short post. Curiosity-driven blogging if you like.&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;



</summary><category term="blogging"/><category term="til"/></entry><entry><title>TIL: Testing different Python versions with uv with-editable and uv-test</title><link href="https://simonwillison.net/2025/Oct/9/uv-test/#atom-tag" rel="alternate"/><published>2025-10-09T03:37:06+00:00</published><updated>2025-10-09T03:37:06+00:00</updated><id>https://simonwillison.net/2025/Oct/9/uv-test/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/python/uv-tests"&gt;TIL: Testing different Python versions with uv with-editable and uv-test&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
While tinkering with upgrading various projects to handle Python 3.14 I finally figured out a universal &lt;code&gt;uv&lt;/code&gt; recipe for running the tests for the current project in any specified version of Python:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uv run --python 3.14 --isolated --with-editable '.[test]' pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This should work in any directory with a &lt;code&gt;pyproject.toml&lt;/code&gt; (or even a &lt;code&gt;setup.py&lt;/code&gt;) that defines a &lt;code&gt;test&lt;/code&gt; set of extra dependencies and uses &lt;code&gt;pytest&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--with-editable '.[test]'&lt;/code&gt; bit ensures that changes you make to that directory will be picked up by future test runs. The &lt;code&gt;--isolated&lt;/code&gt; flag ensures no other environments will affect your test run.&lt;/p&gt;
&lt;p&gt;I like this pattern so much I built a little shell script that uses it, &lt;a href="https://til.simonwillison.net/python/uv-tests#user-content-uv-test"&gt;shown here&lt;/a&gt;. Now I can change to any Python project directory and run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uv-test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or for a different Python version:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uv-test -p 3.11
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I can pass additional &lt;code&gt;pytest&lt;/code&gt; options too:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uv-test -p 3.11 -k permissions
&lt;/code&gt;&lt;/pre&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/testing"&gt;testing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pytest"&gt;pytest&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="testing"/><category term="pytest"/><category term="til"/><category term="uv"/></entry><entry><title>TIL: Running a gpt-oss eval suite against LM Studio on a Mac</title><link href="https://simonwillison.net/2025/Aug/17/gpt-oss-eval-suite/#atom-tag" rel="alternate"/><published>2025-08-17T03:46:21+00:00</published><updated>2025-08-17T03:46:21+00:00</updated><id>https://simonwillison.net/2025/Aug/17/gpt-oss-eval-suite/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/llms/gpt-oss-evals"&gt;TIL: Running a gpt-oss eval suite against LM Studio on a Mac&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
The other day &lt;a href="https://simonwillison.net/2025/Aug/15/inconsistent-performance/#update"&gt;I learned&lt;/a&gt; that OpenAI published a set of evals as part of their gpt-oss model release, described in their cookbook on &lt;a href="https://cookbook.openai.com/articles/gpt-oss/verifying-implementations"&gt;Verifying gpt-oss implementations&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I decided to try and run that eval suite on my own MacBook Pro, against &lt;code&gt;gpt-oss-20b&lt;/code&gt; running inside of LM Studio.&lt;/p&gt;
&lt;p&gt;TLDR: once I had the model running inside LM Studio with a longer than default context limit, the following incantation ran an eval suite in around 3.5 hours:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir /tmp/aime25_openai
OPENAI_API_KEY=x \
  uv run --python 3.13 --with 'gpt-oss[eval]' \
  python -m gpt_oss.evals \
  --base-url http://localhost:1234/v1 \
  --eval aime25 \
  --sampler chat_completions \
  --model openai/gpt-oss-20b \
  --reasoning-effort low \
  --n-threads 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My &lt;a href="https://til.simonwillison.net/llms/gpt-oss-evals"&gt;new TIL&lt;/a&gt; breaks that command down in detail and walks through the underlying eval - AIME 2025, which asks 30 questions (8 times each) that are defined using the following format:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;{"question": "Find the sum of all integer bases $b&amp;gt;9$ for which $17_{b}$ is a divisor of $97_{b}$.", "answer": "70"}&lt;/code&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/local-llms"&gt;local-llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/evals"&gt;evals&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/lm-studio"&gt;lm-studio&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt-oss"&gt;gpt-oss&lt;/a&gt;&lt;/p&gt;



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


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



</summary><category term="github"/><category term="projects"/><category term="python"/><category term="ai"/><category term="til"/><category term="openai"/><category term="generative-ai"/><category term="llms"/><category term="llm"/><category term="github-codespaces"/><category term="anthony-shaw"/></entry><entry><title>TIL: Rate limiting by IP using Cloudflare's rate limiting rules</title><link href="https://simonwillison.net/2025/Jul/3/rate-limiting-by-ip/#atom-tag" rel="alternate"/><published>2025-07-03T21:16:51+00:00</published><updated>2025-07-03T21:16:51+00:00</updated><id>https://simonwillison.net/2025/Jul/3/rate-limiting-by-ip/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/cloudflare/rate-limiting"&gt;TIL: Rate limiting by IP using Cloudflare&amp;#x27;s rate limiting rules&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
My blog started timing out on some requests a few days ago, and it turned out there were misbehaving crawlers that were spidering my &lt;code&gt;/search/&lt;/code&gt; page even though it's restricted by &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I run this site behind Cloudflare and it turns out Cloudflare's WAF (Web Application Firewall) has a rate limiting tool that I could use to restrict requests to &lt;code&gt;/search/*&lt;/code&gt; by a specific IP to a maximum of 5 every 10 seconds.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/rate-limiting"&gt;rate-limiting&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cloudflare"&gt;cloudflare&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;



</summary><category term="rate-limiting"/><category term="security"/><category term="cloudflare"/><category term="til"/></entry><entry><title>TIL: Using Playwright MCP with Claude Code</title><link href="https://simonwillison.net/2025/Jul/1/using-playwright-mcp-with-claude-code/#atom-tag" rel="alternate"/><published>2025-07-01T23:55:09+00:00</published><updated>2025-07-01T23:55:09+00:00</updated><id>https://simonwillison.net/2025/Jul/1/using-playwright-mcp-with-claude-code/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/claude-code/playwright-mcp-claude-code"&gt;TIL: Using Playwright MCP with Claude Code&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Inspired &lt;a href="https://simonwillison.net/2025/Jun/29/agentic-coding/"&gt;by Armin&lt;/a&gt; ("I personally use only one MCP - I only use Playwright") I decided to figure out how to use the official &lt;a href="https://github.com/microsoft/playwright-mcp"&gt;Playwright MCP server&lt;/a&gt; with &lt;a href="https://simonwillison.net/tags/claude-code/"&gt;Claude Code&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It turns out it's easy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claude mcp add playwright npx '@playwright/mcp@latest'
claude
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;claude mcp add&lt;/code&gt; command only affects the current directory by default - it gets persisted in the &lt;code&gt;~/.claude.json&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;Now Claude can use Playwright to automate a Chrome browser! Tell it to "Use playwright mcp to open a browser to example.com" and watch it go - it can navigate pages, submit forms, execute custom JavaScript and take screenshots to feed back into the LLM.&lt;/p&gt;
&lt;p&gt;The browser window stays visible which means you can interact with it too, including signing into websites so Claude can act on your behalf.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/armin-ronacher"&gt;armin-ronacher&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/playwright"&gt;playwright&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/anthropic"&gt;anthropic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="armin-ronacher"/><category term="til"/><category term="playwright"/><category term="ai-assisted-programming"/><category term="anthropic"/><category term="claude"/><category term="claude-code"/></entry><entry><title>TIL: SQLite triggers</title><link href="https://simonwillison.net/2025/May/10/til-sqlite-triggers/#atom-tag" rel="alternate"/><published>2025-05-10T05:20:45+00:00</published><updated>2025-05-10T05:20:45+00:00</updated><id>https://simonwillison.net/2025/May/10/til-sqlite-triggers/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/sqlite/sqlite-triggers"&gt;TIL: SQLite triggers&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I've been doing some work with SQLite triggers recently while working on &lt;a href="https://github.com/simonw/sqlite-chronicle"&gt;sqlite-chronicle&lt;/a&gt;, and I decided I needed a single reference to exactly which triggers are executed for which SQLite actions and what data is available within those triggers.&lt;/p&gt;
&lt;p&gt;I wrote this &lt;a href="https://github.com/simonw/til/blob/main/sqlite/triggers.py"&gt;triggers.py&lt;/a&gt; script to output as much information about triggers as possible, then wired it into a TIL article using &lt;a href="https://cog.readthedocs.io/"&gt;Cog&lt;/a&gt;. The Cog-powered source code for the TIL article &lt;a href="https://github.com/simonw/til/blob/main/sqlite/sqlite-triggers.md?plain=1"&gt;can be seen here&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="sql"/><category term="sqlite"/><category term="til"/></entry><entry><title>TIL: Styling an HTML dialog modal to take the full height of the viewport</title><link href="https://simonwillison.net/2025/Mar/14/styling-an-html-dialog/#atom-tag" rel="alternate"/><published>2025-03-14T23:13:55+00:00</published><updated>2025-03-14T23:13:55+00:00</updated><id>https://simonwillison.net/2025/Mar/14/styling-an-html-dialog/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/css/dialog-full-height"&gt;TIL: Styling an HTML dialog modal to take the full height of the viewport&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I spent some time today trying to figure out how to have a modal &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element present as a full height side panel that animates in from the side. The full height bit was hard, until Natalie helped me figure out that browsers apply a default &lt;code&gt;max-height: calc(100% - 6px - 2em);&lt;/code&gt; rule which needs to be over-ridden.&lt;/p&gt;
&lt;p&gt;Also included: some &lt;a href="https://til.simonwillison.net/css/dialog-full-height#user-content-spelunking-through-the-html-specification"&gt;spelunking through the HTML spec&lt;/a&gt; to figure out where that &lt;code&gt;calc()&lt;/code&gt; expression was first introduced. The answer was &lt;a href="https://github.com/whatwg/html/commit/979af1532"&gt;November 2020&lt;/a&gt;.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/css"&gt;css&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/html"&gt;html&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/natalie-downe"&gt;natalie-downe&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/web-standards"&gt;web-standards&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;



</summary><category term="css"/><category term="html"/><category term="natalie-downe"/><category term="web-standards"/><category term="til"/></entry><entry><title>Using a Tailscale exit node with GitHub Actions</title><link href="https://simonwillison.net/2025/Feb/23/tailscale-exit-node-with-github-actions/#atom-tag" rel="alternate"/><published>2025-02-23T02:49:32+00:00</published><updated>2025-02-23T02:49:32+00:00</updated><id>https://simonwillison.net/2025/Feb/23/tailscale-exit-node-with-github-actions/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/tailscale/tailscale-github-actions"&gt;Using a Tailscale exit node with GitHub Actions&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New TIL. I started running a &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;git scraper&lt;/a&gt; against doge.gov to track changes made to that website over time. The DOGE site runs behind Cloudflare which was blocking requests from the GitHub Actions IP range, but I figured out how to run a Tailscale exit node on my Apple TV and use that to proxy my &lt;a href="https://shot-scraper.datasette.io/"&gt;shot-scraper&lt;/a&gt; requests.&lt;/p&gt;
&lt;p&gt;The scraper is running in &lt;a href="https://github.com/simonw/scrape-doge-gov"&gt;simonw/scrape-doge-gov&lt;/a&gt;. It uses the new &lt;a href="https://shot-scraper.datasette.io/en/stable/har.html"&gt;shot-scraper har&lt;/a&gt; command I added in &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.6"&gt;shot-scraper 1.6&lt;/a&gt; (and improved in &lt;a href="https://github.com/simonw/shot-scraper/releases/tag/1.7"&gt;shot-scraper 1.7&lt;/a&gt;).


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/scraping"&gt;scraping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tailscale"&gt;tailscale&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/git-scraping"&gt;git-scraping&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/shot-scraper"&gt;shot-scraper&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="scraping"/><category term="github-actions"/><category term="tailscale"/><category term="til"/><category term="git-scraping"/><category term="shot-scraper"/></entry><entry><title>TIL: Downloading every video for a TikTok account</title><link href="https://simonwillison.net/2025/Jan/19/til-downloading-every-video-for-a-tiktok-account/#atom-tag" rel="alternate"/><published>2025-01-19T02:05:44+00:00</published><updated>2025-01-19T02:05:44+00:00</updated><id>https://simonwillison.net/2025/Jan/19/til-downloading-every-video-for-a-tiktok-account/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/tiktok/download-all-videos"&gt;TIL: Downloading every video for a TikTok account&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
TikTok may or may not be banned in the USA within the next 24 hours or so. I figured out a gnarly pattern for downloading every video from a specified account, using browser console JavaScript to scrape the video URLs and &lt;a href="https://github.com/yt-dlp/yt-dlp"&gt;yt-dlp&lt;/a&gt; to fetch each video. As a bonus, I included a recipe for generating a Whisper transcript of every video with &lt;a href="https://pypi.org/project/mlx-whisper/"&gt;mlx-whisper&lt;/a&gt; and a hacky way to show a progress bar for the downloads.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/whisper"&gt;whisper&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tiktok"&gt;tiktok&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/speech-to-text"&gt;speech-to-text&lt;/a&gt;&lt;/p&gt;



</summary><category term="til"/><category term="whisper"/><category term="tiktok"/><category term="speech-to-text"/></entry><entry><title>TIL: Using uv to develop Python command-line applications</title><link href="https://simonwillison.net/2024/Oct/24/uv-cli/#atom-tag" rel="alternate"/><published>2024-10-24T05:56:21+00:00</published><updated>2024-10-24T05:56:21+00:00</updated><id>https://simonwillison.net/2024/Oct/24/uv-cli/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/python/uv-cli-apps"&gt;TIL: Using uv to develop Python command-line applications&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I've been increasingly using &lt;a href="https://docs.astral.sh/uv/"&gt;uv&lt;/a&gt; to try out new software (via &lt;code&gt;uvx&lt;/code&gt;) and experiment with new ideas, but I hadn't quite figured out the right way to use it for developing my own projects.&lt;/p&gt;
&lt;p&gt;It turns out I was missing a few things - in particular the fact that there's no need to use &lt;code&gt;uv pip&lt;/code&gt; at all when working with a local development environment, you can get by entirely on &lt;code&gt;uv run&lt;/code&gt; (and maybe &lt;code&gt;uv sync --extra test&lt;/code&gt; to install test dependencies) with no direct invocations of &lt;code&gt;uv pip&lt;/code&gt; at all.&lt;/p&gt;
&lt;p&gt;I bounced &lt;a href="https://gist.github.com/simonw/975dfa41e9b03bca2513a986d9aa3dcf"&gt;a few questions&lt;/a&gt; off Charlie Marsh and filled in the missing gaps - this TIL shows my new uv-powered process for hacking on Python CLI apps built using Click and my &lt;a href="https://github.com/simonw/click-app"&gt;simonw/click-app&lt;/a&gt; cookecutter template.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/cli"&gt;cli&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/packaging"&gt;packaging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pip"&gt;pip&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/cookiecutter"&gt;cookiecutter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/astral"&gt;astral&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/charlie-marsh"&gt;charlie-marsh&lt;/a&gt;&lt;/p&gt;



</summary><category term="cli"/><category term="packaging"/><category term="pip"/><category term="python"/><category term="til"/><category term="cookiecutter"/><category term="uv"/><category term="astral"/><category term="charlie-marsh"/></entry><entry><title>Julia Evans: TIL</title><link href="https://simonwillison.net/2024/Oct/24/julia-evans-til/#atom-tag" rel="alternate"/><published>2024-10-24T05:52:10+00:00</published><updated>2024-10-24T05:52:10+00:00</updated><id>https://simonwillison.net/2024/Oct/24/julia-evans-til/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://jvns.ca/til/"&gt;Julia Evans: TIL&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I've always loved how Julia Evans emphasizes the joy of learning and how you should celebrate every new thing you learn and never be ashamed to admit that you haven't figured something out yet. That attitude was part of my inspiration when I &lt;a href="https://simonwillison.net/2020/Apr/20/self-rewriting-readme/"&gt;started writing TILs&lt;/a&gt; a few years ago.&lt;/p&gt;
&lt;p&gt;Julia just started publishing TILs too, and I'm &lt;a href="https://social.jvns.ca/@b0rk/113351904842806990"&gt;delighted to learn&lt;/a&gt; that this was partially inspired by my own efforts!


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/julia-evans"&gt;julia-evans&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;



</summary><category term="blogging"/><category term="julia-evans"/><category term="til"/></entry><entry><title>Write about what you learn. It pushes you to understand topics better.</title><link href="https://simonwillison.net/2023/Aug/14/write-about-what-you-learn/#atom-tag" rel="alternate"/><published>2023-08-14T14:50:48+00:00</published><updated>2023-08-14T14:50:48+00:00</updated><id>https://simonwillison.net/2023/Aug/14/write-about-what-you-learn/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://addyosmani.com/blog/write-learn/"&gt;Write about what you learn. It pushes you to understand topics better.&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Addy Osmani clearly articulates why writing frequently is such a powerful tool for learning more effectively. This post doesn’t mention TILs but it perfectly encapsulates the value I get from publishing them.

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


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/writing"&gt;writing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/addy-osmani"&gt;addy-osmani&lt;/a&gt;&lt;/p&gt;



</summary><category term="blogging"/><category term="writing"/><category term="til"/><category term="addy-osmani"/></entry><entry><title>Annotated explanation of David Beazley's dataklasses</title><link href="https://simonwillison.net/2021/Dec/20/annotated-explanation-of-dataklasses-by-david-beazley/#atom-tag" rel="alternate"/><published>2021-12-20T05:05:04+00:00</published><updated>2021-12-20T05:05:04+00:00</updated><id>https://simonwillison.net/2021/Dec/20/annotated-explanation-of-dataklasses-by-david-beazley/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://til.simonwillison.net/python/annotated-dataklasses"&gt;Annotated explanation of David Beazley&amp;#x27;s dataklasses&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
David Beazley released a self-described “deliciously evil spin on dataclasses” that uses some deep Python trickery to implement a dataclass style decorator which creates classes that import 15-20 times faster than the original. I put together a heavily annotated version of his code while trying to figure out how all of the different Python tricks in it work.

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


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/david-beazley"&gt;david-beazley&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;



</summary><category term="david-beazley"/><category term="python"/><category term="til"/></entry><entry><title>One year of TILs</title><link href="https://simonwillison.net/2021/May/2/one-year-of-tils/#atom-tag" rel="alternate"/><published>2021-05-02T18:01:44+00:00</published><updated>2021-05-02T18:01:44+00:00</updated><id>https://simonwillison.net/2021/May/2/one-year-of-tils/#atom-tag</id><summary type="html">
    &lt;p&gt;Just over &lt;a href="https://simonwillison.net/2020/Apr/20/self-rewriting-readme/"&gt;a year ago&lt;/a&gt; I started tracking TILs, inspired by &lt;a href="https://github.com/jbranchaud/til"&gt;Josh Branchaud's collection&lt;/a&gt;. I've since published &lt;a href="https://til.simonwillison.net/"&gt;148 TILs&lt;/a&gt; across 43 different topics. It's a great format!&lt;/p&gt;
&lt;p&gt;TIL stands for Today I Learned. The thing I like most about TILs is that they drop the barrier to publishing something online to almost nothing.&lt;/p&gt;
&lt;p&gt;If I'm writing a blog entry, I feel like it needs to say something new. This pressure for originality leads to vast numbers of incomplete, draft posts and a sporadic publishing schedule that trends towards not publishing anything at all.&lt;/p&gt;
&lt;p&gt;(Establishing a &lt;a href="https://simonwillison.net/tags/weeknotes/"&gt;weeknotes habit&lt;/a&gt; has helped enormously here too.)&lt;/p&gt;
&lt;p&gt;The bar for a TIL is literally "did I just learn something?" - they effectively act as a public notebook.&lt;/p&gt;
&lt;p&gt;They also reflect my values as a software engineer. The thing I love most about this career is that the opportunities to learn new things never reduce - there will always be new sub-disciplines to explore, and I aspire to learn something new every single working day.&lt;/p&gt;
&lt;p&gt;My hope is that by publishing a constant stream of TILs I can reinforce the idea that even if you've been working in this industry for twenty years there will always be new things to learn, and learning any new trick - even the most basic thing - should be celebrated.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/blogging"&gt;blogging&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="blogging"/><category term="til"/></entry><entry><title>Weeknotes: Working on my screenplay</title><link href="https://simonwillison.net/2020/May/14/weeknotes-working-my-screenplay/#atom-tag" rel="alternate"/><published>2020-05-14T04:53:46+00:00</published><updated>2020-05-14T04:53:46+00:00</updated><id>https://simonwillison.net/2020/May/14/weeknotes-working-my-screenplay/#atom-tag</id><summary type="html">
    &lt;p&gt;I'm taking an Introduction to Screenwriting course with Adam Tobin at Stanford, and my partial screenplay is due this week. I'm pulling together some scenes that tell the story of the Russian 1917 February Revolution and the fall of the Tsar through the lens of the craftsmen working on the Tsar's last Fabergé egg. So I've not been spending much time on anything else.&lt;/p&gt;

&lt;p&gt;Some brief bullet points for this week's software projects:&lt;/p&gt;

&lt;ul&gt;&lt;li&gt;Released version 0.1 of &lt;a href="https://github.com/simonw/datasette-media"&gt;datasette-media&lt;/a&gt;, a new plugin that allows Datasette to serve files from disk based on executing a SQL query to find the file to return. I'm building it to help make &lt;a href="https://github.com/dogsheep/photos-to-sqlite"&gt;photos-to-sqlite&lt;/a&gt; more immediately useful.&lt;/li&gt;&lt;li&gt;Released &lt;a href="https://datasette.readthedocs.io/en/stable/changelog.html#v0-42"&gt;Datasette 0.42&lt;/a&gt; with improved (and &lt;a href="https://datasette.readthedocs.io/en/stable/internals.html#database-execute"&gt;now documented&lt;/a&gt;) internal methods to allow plugins to execute read-only SQL queries. I needed these for &lt;code&gt;datasette-media&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;Released &lt;a href="https://sqlite-utils.readthedocs.io/en/stable/changelog.html#v2-9"&gt;sqlite-utils 2.9&lt;/a&gt; with new CLI commands &lt;code&gt;sqlite-utils drop-table&lt;/code&gt; and &lt;code&gt;sqlite-utils drop-view&lt;/code&gt;.&lt;/li&gt;&lt;li&gt;Released &lt;a href="https://sqlite-utils.readthedocs.io/en/stable/changelog.html#v2-9-1"&gt;sqlite-utils 2.9.1&lt;/a&gt; with a tiny cosmetic improvement: the &lt;a href="https://pypi.org/project/sqlite-utils/"&gt;PyPI project page&lt;/a&gt; now shows project links! See &lt;a href="https://github.com/simonw/til/blob/master/pypi/project-links.md"&gt;this TIL&lt;/a&gt; for details.&lt;/li&gt;&lt;/ul&gt;

&lt;p&gt;I've also started adding changelog badges to various projects, showing the latest release version according to GitHub and linking to that project's changelog. &lt;a href="https://github.com/simonw/datasette/blob/master/README.md"&gt;Datasette&lt;/a&gt;, &lt;a href="https://github.com/dogsheep/photos-to-sqlite/blob/master/README.md"&gt;photos-to-sqlite&lt;/a&gt;, &lt;a href="https://github.com/simonw/sqlite-utils/blob/master/README.md"&gt;sqlite-utils&lt;/a&gt; all have these now.&lt;/p&gt;

&lt;h3&gt;TIL this week&lt;/h3&gt;

&lt;ul&gt;&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/master/python/build-official-docs.md"&gt;Build the official Python documentation locally&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/master/markdown/converting-to-markdown.md"&gt;Converting HTML and rich-text to Markdown&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/master/pypi/project-links.md"&gt;Adding project links to PyPI&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/stanford"&gt;stanford&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/screen-writing"&gt;screen-writing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="stanford"/><category term="screen-writing"/><category term="datasette"/><category term="weeknotes"/><category term="til"/></entry><entry><title>Restricting SSH connections to devices within a Tailscale network</title><link href="https://simonwillison.net/2020/Apr/23/restricting-ssh-connections-tailscale/#atom-tag" rel="alternate"/><published>2020-04-23T18:28:05+00:00</published><updated>2020-04-23T18:28:05+00:00</updated><id>https://simonwillison.net/2020/Apr/23/restricting-ssh-connections-tailscale/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/til/blob/master/tailscale/lock-down-sshd.md"&gt;Restricting SSH connections to devices within a Tailscale network&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
TIL how to run SSH on a VPS instance (in this case Amazon Lightsail) such that it can only be SSHd to by devices connected to a private Tailscale VPN.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/security"&gt;security&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ssh"&gt;ssh&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tailscale"&gt;tailscale&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;



</summary><category term="security"/><category term="ssh"/><category term="tailscale"/><category term="til"/></entry><entry><title>Using a self-rewriting README powered by GitHub Actions to track TILs</title><link href="https://simonwillison.net/2020/Apr/20/self-rewriting-readme/#atom-tag" rel="alternate"/><published>2020-04-20T01:38:15+00:00</published><updated>2020-04-20T01:38:15+00:00</updated><id>https://simonwillison.net/2020/Apr/20/self-rewriting-readme/#atom-tag</id><summary type="html">
    &lt;p&gt;I've started tracking TILs - Today I Learneds - inspired by this &lt;a href="https://github.com/jbranchaud/til"&gt;five-year-and-counting collection&lt;/a&gt; by Josh Branchaud on GitHub (found &lt;a href="https://news.ycombinator.com/item?id=22908044"&gt;via Hacker News&lt;/a&gt;). I'm keeping mine in GitHub too, and using GitHub Actions to automatically generate an index page README in the repository and a SQLite-backed search engine.&lt;/p&gt;

&lt;h3 id="tils"&gt;TILs&lt;/h3&gt;

&lt;p&gt;Josh describes his TILs like this:&lt;/p&gt;

&lt;blockquote&gt;&lt;p&gt;A collection of concise write-ups on small things I learn day to day across a variety of languages and technologies. These are things that don't really warrant a full blog post.&lt;/p&gt;&lt;/blockquote&gt;

&lt;p&gt;This really resonated with me. I have five main places for writing at the moment.&lt;/p&gt;

&lt;ul&gt;&lt;li&gt;This blog, for long-form content and &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;.&lt;/li&gt;&lt;li&gt;&lt;a href="https://twitter.com/simonw"&gt;Twitter&lt;/a&gt;, for tweets - though Twitter threads are tending towards a long-form medium these days. My &lt;a href="https://twitter.com/simonw/status/1077737871602110466"&gt;Spider-Verse behind-the-scenes thread&lt;/a&gt; ran for nearly a year!&lt;/li&gt;&lt;li&gt;My &lt;a href="https://simonwillison.net/search/?type=blogmark"&gt;blogmarks&lt;/a&gt; - links plus short form commentary.&lt;/li&gt;&lt;li&gt;&lt;a href="https://www.niche-museums.com/"&gt;Niche Museums&lt;/a&gt; - effectively a blog about visits to tiny museums. It's on hiatus during the pandemic though.&lt;/li&gt;&lt;li&gt;GitHub issues. I've &lt;a href="https://simonwillison.net/2020/Apr/8/weeknotes-zeit-now-v2/#migrating-my-projects"&gt;formed the habit&lt;/a&gt; of thinking out loud in issues, replying to myself with comments as I figure things out.&lt;/li&gt;&lt;/ul&gt;

&lt;p&gt;What's missing is exactly what TILs provide: somewhere to dump a couple of paragraphs about a new trick I've learned, with chronological order being less important than just getting them written down somewhere.&lt;/p&gt;

&lt;p&gt;I've intermittently used &lt;a href="https://gist.github.com/simonw"&gt;gists&lt;/a&gt; for things like this in the past, but having them in an organized repo feels like a much less ad-hoc solution.&lt;/p&gt;

&lt;p&gt;So I've started my own collection of TILs in my &lt;a href="https://github.com/simonw/til"&gt;simonw/til&lt;/a&gt; GitHub repository.&lt;/p&gt;

&lt;h3 id="automating-readme"&gt;Automating the README index page with GitHub Actions&lt;/h3&gt;

&lt;p&gt;The biggest feature I miss from &lt;a href="https://simonwillison.net/2018/Aug/25/restructuredtext/"&gt;reStructuredText&lt;/a&gt; when I'm working in Markdown is automatic &lt;a href="http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#table-of-contents"&gt;tables of content&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For my TILs I wanted the index page on GitHub to display all of them. But I didn't want to have to update that page by hand every time I added one - especially since I'll often be creating them through the GitHub web interface which doesn't support editing multiple files in a single commit.&lt;/p&gt;

&lt;p&gt;I've been getting a lot done with GitHub Actions recently. This felt like an opportunity to put them to more use.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;a href="https://github.com/simonw/til/blob/0abdc32464f1bc726abebdbc147b945d22bae7a8/.github/workflows/build.yml"&gt;a GitHub Actions workflow&lt;/a&gt; that automatically updates the README page in the repo every time a new TIL markdown file is added or updated!&lt;/p&gt;

&lt;p&gt;Here's an outline of how it works:&lt;/p&gt;

&lt;ul&gt;&lt;li&gt;It runs on pushes to the master branch (no-one else can trigger it by sending me a pull request). It ignores commits that include the &lt;code&gt;README.md&lt;/code&gt; file itself - otherwise commits to that file made by the workflow could trigger further runs of the same workflow. UPDATE: Apparently &lt;a href="https://twitter.com/seantallen/status/1252064311591215104"&gt;this isn't necessary&lt;/a&gt;.&lt;/li&gt;&lt;li&gt;It checks out the full repo history using &lt;a href="https://github.com/actions/checkout"&gt;actions/checkout@v2&lt;/a&gt; with the &lt;code&gt;fetch-depth: 0&lt;/code&gt; option. This is needed because my script derives created/updated dates for each TIL by inspecting the git history. I &lt;a href="https://github.com/simonw/museums/issues/22"&gt;learned a few days ago&lt;/a&gt; that this mechanism breaks if you only do a shallow check-out of the most recent commit!&lt;/li&gt;&lt;li&gt;It sets up Python, configures pip caching and installs dependencies from my &lt;a href="https://github.com/simonw/til/blob/master/requirements.txt"&gt;requirements.txt&lt;/a&gt;.&lt;/li&gt;&lt;li&gt;It runs my &lt;a href="https://github.com/simonw/til/blob/0abdc32464f1bc726abebdbc147b945d22bae7a8/build_database.py"&gt;build_database.py script&lt;/a&gt;, which uses &lt;a href="https://gitpython.readthedocs.io/"&gt;GitPython&lt;/a&gt; to scan for all &lt;code&gt;*/*.md&lt;/code&gt; files and find their created and updated dates, then uses &lt;a href="https://sqlite-utils.readthedocs.io/"&gt;sqlite-utils&lt;/a&gt; to write the results to a SQLite database on the GitHub Actions temporary disk.&lt;/li&gt;&lt;li&gt;It runs &lt;a href="https://github.com/simonw/til/blob/0abdc32464f1bc726abebdbc147b945d22bae7a8/update_readme.py"&gt;update_readme.py&lt;/a&gt; which reads from that SQLite database and uses it to generate the markdown index section for the README. Then it opens the README and replaces the section between the &lt;code&gt;&amp;lt;!-- index starts --&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;!-- index ends --&amp;gt;&lt;/code&gt; with the newly generated index.&lt;/li&gt;&lt;li&gt;It uses &lt;code&gt;git diff&lt;/code&gt; to detect if the README has changed, then if it has it runs &lt;code&gt;git commit&lt;/code&gt; and &lt;code&gt;git push&lt;/code&gt; to commit those changes. See my TIL &lt;a href="https://github.com/simonw/til/blob/master/github-actions/commit-if-file-changed.md"&gt;Commit a file if it changed&lt;/a&gt; for details on that pattern.&lt;/li&gt;&lt;/ul&gt;

&lt;p&gt;I &lt;em&gt;really&lt;/em&gt; like this pattern.&lt;/p&gt;

&lt;p&gt;I'm a big fan of keeping content in a git repository. Every CMS I've ever worked on has eventually evolved a desire to provide revision tracking, and building that into a regular database schema is never particularly pleasant. Git solves content versioning extremely effectively.&lt;/p&gt;

&lt;p&gt;Having a GitHub repository that can update itself to maintain things like index pages feels like a technique that could be applied to all kinds of other content-related problems.&lt;/p&gt;

&lt;p&gt;I'm also keen on the idea of using SQLite databases as intermediary storage as part of an Actions workflow. It's a simple but powerful way for one step in an action to generate structured data that can then be consumed by subsequent steps.&lt;/p&gt;

&lt;h3&gt;Implementing search with Datasette&lt;/h3&gt;

&lt;p&gt;Unsurprisingly, the other reason I'm using SQLite here is so I can deploy a database using &lt;a href="https://datasette.readthedocs.io/"&gt;Datasette&lt;/a&gt;. The last two steps of the workflow look like this:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Setup Node.js
  uses: actions/setup-node@v1
  with:
    node-version: '12.x'
- name: Deploy Datasette using Zeit Now
  env:
    NOW_TOKEN: ${{ secrets.NOW_TOKEN }}
  run: |-
    datasette publish now2 til.db \
      --token $NOW_TOKEN \
      --project simon-til \
      --metadata metadata.json \
      --install py-gfm \
      --install datasette-render-markdown \
      --install datasette-template-sql \
      --template-dir templates&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This installs Node.js, then uses &lt;a href="https://zeit.co/now"&gt;Zeit Now&lt;/a&gt; (via &lt;a href="https://github.com/simonw/datasette-publish-now"&gt;datasette-publish-now&lt;/a&gt;) to publish the generated &lt;code&gt;til.db&lt;/code&gt; SQLite database file to a Datasette instance accessible at &lt;a href="https://til.simonwillison.net/"&gt;til.simonwillison.net&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2020/Simon_Willison__TIL.png" alt="Screenshot of til.simonwillison.net" style="max-width: 100%" /&gt;&lt;/p&gt;

&lt;p&gt;I'm reusing &lt;a href="https://simonwillison.net/2019/Nov/25/niche-museums/"&gt;a bunch of tricks&lt;/a&gt; from my Niche Museums website here. The site is a standard Datasette instance with a custom &lt;a href="https://github.com/simonw/til/blob/0abdc32464f1bc726abebdbc147b945d22bae7a8/templates/index.html"&gt;index.html&lt;/a&gt; template that uses &lt;a href="https://github.com/simonw/datasette-template-sql"&gt;datasette-template-sql&lt;/a&gt; to display the TILs. Here's that template section in full:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{% for row in sql("select distinct topic from til order by topic") %}
    &amp;lt;h2&amp;gt;{{ row.topic }}&amp;lt;/h2&amp;gt;
    &amp;lt;ul&amp;gt;
        {% for til in sql("select * from til where topic = '" + row.topic + "'") %}
            &amp;lt;li&amp;gt;&amp;lt;a href="{{ til.url }}"&amp;gt;{{ til.title }}&amp;lt;/a&amp;gt; - {{ til.created[:10] }}&amp;lt;/li&amp;gt;
        {% endfor %}
    &amp;lt;/ul&amp;gt;
{% endfor %}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The search interface is powered by a custom SQL query in &lt;a href="https://github.com/simonw/til/blob/0abdc32464f1bc726abebdbc147b945d22bae7a8/metadata.json"&gt;metadata.json&lt;/a&gt; that looks like this:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;select
    til_fts.rank,
    til.*
from til
join til_fts on til.rowid = til_fts.rowid
where
    til_fts match case
        :q
        when '' then '*'
        else escape_fts(:q)
    end
order by
    til_fts.rank limit 20&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A custom &lt;a href="https://github.com/simonw/til/blob/0abdc32464f1bc726abebdbc147b945d22bae7a8/templates/query-til-search.html"&gt;query-til-search.html&lt;/a&gt; template then renders the search results.&lt;/p&gt;

&lt;h3&gt;A powerful combination&lt;/h3&gt;

&lt;p&gt;I'm pretty happy with what I have here - it's definitely good enough to solve my TIL publishing needs. I'll probably add an Atom feed &lt;a href="https://simonwillison.net/2019/Dec/3/datasette-atom/"&gt;using datasette-atom&lt;/a&gt; at some point.&lt;/p&gt;

&lt;p&gt;I hope this helps illustrate how powerful the combination of GitHub Actions, Datasette and Zeit Now or Cloud Run can be. I'm running an increasing number of projects on that combination, and the price, performance and ease of implementation continue to impress.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/markdown"&gt;markdown&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/til"&gt;til&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="github"/><category term="projects"/><category term="markdown"/><category term="datasette"/><category term="github-actions"/><category term="til"/></entry></feed>